gimpact 0.1.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/LICENCE +21 -0
- package/README.md +22 -0
- package/dist/index.js +2061 -0
- package/package.json +38 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2061 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import chalk7 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/core/analysis/filters/file-pattern-filter.ts
|
|
8
|
+
import { simpleGit } from "simple-git";
|
|
9
|
+
var DEFAULT_EXCLUDE_PATTERNS = [
|
|
10
|
+
// Lock files
|
|
11
|
+
"**/*.lock",
|
|
12
|
+
"**/package-lock.json",
|
|
13
|
+
"**/yarn.lock",
|
|
14
|
+
"**/pnpm-lock.yaml",
|
|
15
|
+
"**/uv.lock",
|
|
16
|
+
"**/Cargo.lock",
|
|
17
|
+
"**/Gemfile.lock",
|
|
18
|
+
"**/Pipfile.lock",
|
|
19
|
+
"**/poetry.lock",
|
|
20
|
+
"**/composer.lock",
|
|
21
|
+
"**/go.sum",
|
|
22
|
+
"**/go.mod",
|
|
23
|
+
// Build outputs
|
|
24
|
+
"**/node_modules/**",
|
|
25
|
+
"**/dist/**",
|
|
26
|
+
"**/build/**",
|
|
27
|
+
"**/out/**",
|
|
28
|
+
"**/.next/**",
|
|
29
|
+
"**/.nuxt/**",
|
|
30
|
+
"**/.cache/**",
|
|
31
|
+
"**/coverage/**",
|
|
32
|
+
"**/.coverage/**",
|
|
33
|
+
// Generated files
|
|
34
|
+
"**/openapi.json",
|
|
35
|
+
"**/openapi.yaml",
|
|
36
|
+
"**/openapi.yml",
|
|
37
|
+
"**/*.generated.*",
|
|
38
|
+
"**/*.pb.go",
|
|
39
|
+
"**/*.pb.ts",
|
|
40
|
+
"**/*.pb.js",
|
|
41
|
+
// IDE and editor files
|
|
42
|
+
"**/.idea/**",
|
|
43
|
+
"**/.vscode/**",
|
|
44
|
+
"**/.DS_Store",
|
|
45
|
+
"**/Thumbs.db",
|
|
46
|
+
// Logs and temporary files
|
|
47
|
+
"**/*.log",
|
|
48
|
+
"**/*.tmp",
|
|
49
|
+
"**/*.temp"
|
|
50
|
+
];
|
|
51
|
+
function matchesPattern(filePath, patterns) {
|
|
52
|
+
for (const pattern of patterns) {
|
|
53
|
+
if (matchesGlob(filePath, pattern)) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function matchesGlob(filePath, pattern) {
|
|
60
|
+
const normalizedPath = filePath.replace(/^\.\//, "");
|
|
61
|
+
let normalizedPattern = pattern.replace(/^\.\//, "");
|
|
62
|
+
normalizedPattern = normalizedPattern.replace(/\*\*/g, "\0RECURSIVE\0");
|
|
63
|
+
let regexPattern = normalizedPattern.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, ".");
|
|
64
|
+
regexPattern = regexPattern.replace(/\0RECURSIVE\0/g, ".*?");
|
|
65
|
+
const originalPattern = pattern.replace(/^\.\//, "");
|
|
66
|
+
if (!originalPattern.startsWith("/") && !originalPattern.startsWith("**")) {
|
|
67
|
+
regexPattern = `.*?${regexPattern}`;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
71
|
+
return regex.test(normalizedPath);
|
|
72
|
+
} catch (_error) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function areGitIgnored(filePaths, git) {
|
|
77
|
+
if (filePaths.length === 0) {
|
|
78
|
+
return /* @__PURE__ */ new Set();
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const result = await git.checkIgnore(filePaths);
|
|
82
|
+
return new Set(result);
|
|
83
|
+
} catch {
|
|
84
|
+
return /* @__PURE__ */ new Set();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
var FilePatternFilter = class {
|
|
88
|
+
excludePatterns;
|
|
89
|
+
includeDirectory;
|
|
90
|
+
respectGitignore;
|
|
91
|
+
repoRoot;
|
|
92
|
+
gitClient = null;
|
|
93
|
+
constructor(excludePatterns = [], respectGitignore = true, repoRoot = process.cwd(), includeDirectory) {
|
|
94
|
+
this.excludePatterns = [...DEFAULT_EXCLUDE_PATTERNS, ...excludePatterns];
|
|
95
|
+
this.respectGitignore = respectGitignore;
|
|
96
|
+
this.repoRoot = repoRoot;
|
|
97
|
+
this.includeDirectory = includeDirectory;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get or create git client instance
|
|
101
|
+
*/
|
|
102
|
+
getGitClient() {
|
|
103
|
+
if (!this.gitClient) {
|
|
104
|
+
this.gitClient = simpleGit(this.repoRoot);
|
|
105
|
+
}
|
|
106
|
+
return this.gitClient;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Check if a file should be excluded (pattern matching only, no git check)
|
|
110
|
+
*/
|
|
111
|
+
shouldExcludeByPattern(filePath) {
|
|
112
|
+
if (this.includeDirectory) {
|
|
113
|
+
const normalizedDir = this.includeDirectory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
114
|
+
const normalizedPath = filePath.replace(/^\.\//, "");
|
|
115
|
+
const isInDirectory = normalizedPath === normalizedDir || normalizedPath.startsWith(`${normalizedDir}/`);
|
|
116
|
+
if (!isInDirectory) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return matchesPattern(filePath, this.excludePatterns);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Filter ownership analysis result
|
|
124
|
+
*/
|
|
125
|
+
async filter(result) {
|
|
126
|
+
const filteredFiles = {};
|
|
127
|
+
const filteredDirectories = {};
|
|
128
|
+
const filteredAuthors = {};
|
|
129
|
+
const filesToCheckGitIgnore = [];
|
|
130
|
+
const fileOwnershipMap = /* @__PURE__ */ new Map();
|
|
131
|
+
for (const [filePath, fileOwnership] of Object.entries(result.files)) {
|
|
132
|
+
if (this.shouldExcludeByPattern(filePath)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (this.respectGitignore) {
|
|
136
|
+
filesToCheckGitIgnore.push(filePath);
|
|
137
|
+
fileOwnershipMap.set(filePath, fileOwnership);
|
|
138
|
+
} else {
|
|
139
|
+
filteredFiles[filePath] = fileOwnership;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (this.respectGitignore && filesToCheckGitIgnore.length > 0) {
|
|
143
|
+
const gitClient = this.getGitClient();
|
|
144
|
+
const ignoredFiles = await areGitIgnored(filesToCheckGitIgnore, gitClient);
|
|
145
|
+
for (const filePath of filesToCheckGitIgnore) {
|
|
146
|
+
if (!ignoredFiles.has(filePath)) {
|
|
147
|
+
const fileOwnership = fileOwnershipMap.get(filePath);
|
|
148
|
+
if (fileOwnership) {
|
|
149
|
+
filteredFiles[filePath] = fileOwnership;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const directoryFileCounts = {};
|
|
155
|
+
for (const [filePath, fileOwnership] of Object.entries(filteredFiles)) {
|
|
156
|
+
const directory = this.getDirectory(filePath);
|
|
157
|
+
if (!directoryFileCounts[directory]) {
|
|
158
|
+
directoryFileCounts[directory] = {};
|
|
159
|
+
}
|
|
160
|
+
const owner = fileOwnership.owner;
|
|
161
|
+
directoryFileCounts[directory][owner] = (directoryFileCounts[directory][owner] || 0) + 1;
|
|
162
|
+
}
|
|
163
|
+
for (const [directory, ownerCounts] of Object.entries(directoryFileCounts)) {
|
|
164
|
+
if (this.includeDirectory) {
|
|
165
|
+
const normalizedDir = this.includeDirectory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
166
|
+
const normalizedDirectory = directory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
167
|
+
if (!normalizedDirectory.startsWith(`${normalizedDir}/`) && normalizedDirectory !== normalizedDir) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
let primaryOwner = "";
|
|
172
|
+
let maxFiles = 0;
|
|
173
|
+
for (const [owner, count] of Object.entries(ownerCounts)) {
|
|
174
|
+
if (count > maxFiles) {
|
|
175
|
+
maxFiles = count;
|
|
176
|
+
primaryOwner = owner;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (primaryOwner) {
|
|
180
|
+
const totalFiles = Object.values(ownerCounts).reduce((sum, count) => sum + count, 0);
|
|
181
|
+
const totalLines = Object.entries(filteredFiles).filter(([path]) => this.getDirectory(path) === directory).reduce((sum, [, ownership]) => sum + ownership.totalLines, 0);
|
|
182
|
+
filteredDirectories[directory] = {
|
|
183
|
+
directory,
|
|
184
|
+
owner: primaryOwner,
|
|
185
|
+
share: totalFiles > 0 ? Math.round(maxFiles / totalFiles * 100) : 0,
|
|
186
|
+
ownerFiles: maxFiles,
|
|
187
|
+
totalFiles,
|
|
188
|
+
totalLines
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const [filePath, fileOwnership] of Object.entries(filteredFiles)) {
|
|
193
|
+
const owner = fileOwnership.owner;
|
|
194
|
+
if (!filteredAuthors[owner]) {
|
|
195
|
+
filteredAuthors[owner] = {
|
|
196
|
+
author: owner,
|
|
197
|
+
files: [],
|
|
198
|
+
totalFiles: 0,
|
|
199
|
+
totalLines: 0
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
filteredAuthors[owner].files.push({
|
|
203
|
+
file: filePath,
|
|
204
|
+
lines: fileOwnership.ownerLines,
|
|
205
|
+
share: fileOwnership.share
|
|
206
|
+
});
|
|
207
|
+
filteredAuthors[owner].totalFiles++;
|
|
208
|
+
filteredAuthors[owner].totalLines += fileOwnership.ownerLines;
|
|
209
|
+
}
|
|
210
|
+
for (const author of Object.values(filteredAuthors)) {
|
|
211
|
+
author.files.sort((a, b) => b.lines - a.lines);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
files: filteredFiles,
|
|
215
|
+
directories: filteredDirectories,
|
|
216
|
+
authors: filteredAuthors
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
getDirectory(filePath) {
|
|
220
|
+
const lastSlashIndex = filePath.lastIndexOf("/");
|
|
221
|
+
if (lastSlashIndex === -1) {
|
|
222
|
+
return "./";
|
|
223
|
+
}
|
|
224
|
+
return filePath.substring(0, lastSlashIndex + 1);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// src/core/analysis/filters/filter-chain.ts
|
|
229
|
+
var FilterChain = class {
|
|
230
|
+
filters = [];
|
|
231
|
+
addFilter(filter) {
|
|
232
|
+
this.filters.push(filter);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
apply(data) {
|
|
236
|
+
return this.filters.reduce((result, filter) => filter.apply(result), data);
|
|
237
|
+
}
|
|
238
|
+
isEmpty() {
|
|
239
|
+
return this.filters.length === 0;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// src/core/analysis/filters/min-commits-filter.ts
|
|
244
|
+
var MinCommitsFilter = class {
|
|
245
|
+
constructor(minCommits) {
|
|
246
|
+
this.minCommits = minCommits;
|
|
247
|
+
}
|
|
248
|
+
apply(result) {
|
|
249
|
+
if (Array.isArray(result)) {
|
|
250
|
+
return result.filter((item) => item.stats.commits >= this.minCommits);
|
|
251
|
+
} else {
|
|
252
|
+
const filteredStats = {};
|
|
253
|
+
const filteredEfficiency = {};
|
|
254
|
+
for (const [author, authorStats] of Object.entries(result.stats)) {
|
|
255
|
+
if (authorStats.commits >= this.minCommits) {
|
|
256
|
+
filteredStats[author] = authorStats;
|
|
257
|
+
if (result.efficiency[author]) {
|
|
258
|
+
filteredEfficiency[author] = result.efficiency[author];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
stats: filteredStats,
|
|
264
|
+
efficiency: filteredEfficiency
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// src/core/analysis/log-parsers/aggregate-log-parser.ts
|
|
271
|
+
function parseAggregateStats(log, authors) {
|
|
272
|
+
const stats = {};
|
|
273
|
+
const authorFiles = {};
|
|
274
|
+
const lines = log.split("\n");
|
|
275
|
+
let currentAuthor = "";
|
|
276
|
+
const authorFilter = authors?.map((a) => a.toLowerCase());
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
const trimmedLine = line.trim();
|
|
279
|
+
if (trimmedLine.length === 0) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (trimmedLine.includes(" ")) {
|
|
283
|
+
const parts = trimmedLine.split(" ");
|
|
284
|
+
const insertions = parseInt(parts[0], 10) || 0;
|
|
285
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
286
|
+
const filePath = parts[2];
|
|
287
|
+
if (currentAuthor && stats[currentAuthor]) {
|
|
288
|
+
stats[currentAuthor].insertions += insertions;
|
|
289
|
+
stats[currentAuthor].deletions += deletions;
|
|
290
|
+
if (filePath) {
|
|
291
|
+
authorFiles[currentAuthor].add(filePath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
currentAuthor = trimmedLine;
|
|
296
|
+
if (authorFilter && !authorFilter.includes(currentAuthor.toLowerCase())) {
|
|
297
|
+
currentAuthor = "";
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!stats[currentAuthor]) {
|
|
301
|
+
stats[currentAuthor] = {
|
|
302
|
+
commits: 0,
|
|
303
|
+
insertions: 0,
|
|
304
|
+
deletions: 0,
|
|
305
|
+
filesTouched: 0
|
|
306
|
+
};
|
|
307
|
+
authorFiles[currentAuthor] = /* @__PURE__ */ new Set();
|
|
308
|
+
}
|
|
309
|
+
stats[currentAuthor].commits++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const author of Object.keys(stats)) {
|
|
313
|
+
stats[author].filesTouched = authorFiles[author]?.size ?? 0;
|
|
314
|
+
}
|
|
315
|
+
return stats;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/core/analysis/log-parsers/efficiency-log-parser.ts
|
|
319
|
+
function parseEfficiencyLog(log) {
|
|
320
|
+
const commits = [];
|
|
321
|
+
const lines = log.split("\n");
|
|
322
|
+
let currentCommit = null;
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
const trimmedLine = line.trim();
|
|
325
|
+
if (trimmedLine.length === 0) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (trimmedLine.includes("|") && !trimmedLine.includes(" ")) {
|
|
329
|
+
if (currentCommit) {
|
|
330
|
+
commits.push(currentCommit);
|
|
331
|
+
}
|
|
332
|
+
const parts = trimmedLine.split("|");
|
|
333
|
+
if (parts.length >= 2) {
|
|
334
|
+
currentCommit = {
|
|
335
|
+
author: parts[0],
|
|
336
|
+
date: new Date(parts[1]),
|
|
337
|
+
insertions: 0,
|
|
338
|
+
deletions: 0,
|
|
339
|
+
files: []
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
} else if (trimmedLine.includes(" ") && currentCommit) {
|
|
343
|
+
const parts = trimmedLine.split(" ");
|
|
344
|
+
const insertions = parseInt(parts[0], 10) || 0;
|
|
345
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
346
|
+
const filePath = parts[2];
|
|
347
|
+
currentCommit.insertions += insertions;
|
|
348
|
+
currentCommit.deletions += deletions;
|
|
349
|
+
if (filePath) {
|
|
350
|
+
currentCommit.files.push(filePath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (currentCommit) {
|
|
355
|
+
commits.push(currentCommit);
|
|
356
|
+
}
|
|
357
|
+
return commits;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/core/analysis/log-parsers/ownership-log-parser.ts
|
|
361
|
+
function parseOwnershipStats(log, authors) {
|
|
362
|
+
const fileAuthorLines = {};
|
|
363
|
+
const fileLastCommitDates = {};
|
|
364
|
+
const lines = log.split("\n");
|
|
365
|
+
let currentAuthor = "";
|
|
366
|
+
let currentCommitDate = null;
|
|
367
|
+
const authorFilter = authors?.map((a) => a.toLowerCase());
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
const trimmedLine = line.trim();
|
|
370
|
+
if (trimmedLine.length === 0) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (trimmedLine.startsWith("AUTHOR:")) {
|
|
374
|
+
const authorPart = trimmedLine.replace("AUTHOR:", "").trim();
|
|
375
|
+
if (authorPart.includes("|DATE:")) {
|
|
376
|
+
const parts = authorPart.split("|DATE:");
|
|
377
|
+
currentAuthor = parts[0].trim();
|
|
378
|
+
const dateStr = parts[1]?.trim();
|
|
379
|
+
if (dateStr) {
|
|
380
|
+
currentCommitDate = new Date(dateStr);
|
|
381
|
+
if (Number.isNaN(currentCommitDate.getTime())) {
|
|
382
|
+
currentCommitDate = null;
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
currentCommitDate = null;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
currentAuthor = authorPart;
|
|
389
|
+
currentCommitDate = null;
|
|
390
|
+
}
|
|
391
|
+
fetch("http://127.0.0.1:7242/ingest/f771081e-8bc3-4743-9457-44201273bd79", {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: { "Content-Type": "application/json" },
|
|
394
|
+
body: JSON.stringify({
|
|
395
|
+
location: "ownership-log-parser.ts:30",
|
|
396
|
+
message: "Author line parsed",
|
|
397
|
+
data: { currentAuthor, isEmpty: !currentAuthor, length: currentAuthor.length },
|
|
398
|
+
timestamp: Date.now(),
|
|
399
|
+
sessionId: "debug-session",
|
|
400
|
+
runId: "run1",
|
|
401
|
+
hypothesisId: "B"
|
|
402
|
+
})
|
|
403
|
+
}).catch(() => {
|
|
404
|
+
});
|
|
405
|
+
if (authorFilter && !authorFilter.includes(currentAuthor.toLowerCase())) {
|
|
406
|
+
currentAuthor = "";
|
|
407
|
+
}
|
|
408
|
+
} else if (trimmedLine.includes(" ") && currentAuthor) {
|
|
409
|
+
const parts = trimmedLine.split(" ");
|
|
410
|
+
if (parts.length >= 3) {
|
|
411
|
+
const insertions = parseInt(parts[0], 10) || 0;
|
|
412
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
413
|
+
let filePath = parts[2];
|
|
414
|
+
if (!filePath) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const originalFilePath = filePath;
|
|
418
|
+
filePath = normalizeRenameNotation(filePath);
|
|
419
|
+
fetch("http://127.0.0.1:7242/ingest/f771081e-8bc3-4743-9457-44201273bd79", {
|
|
420
|
+
method: "POST",
|
|
421
|
+
headers: { "Content-Type": "application/json" },
|
|
422
|
+
body: JSON.stringify({
|
|
423
|
+
location: "ownership-log-parser.ts:42",
|
|
424
|
+
message: "File path parsed",
|
|
425
|
+
data: {
|
|
426
|
+
originalFilePath,
|
|
427
|
+
normalizedFilePath: filePath,
|
|
428
|
+
hasRenameNotation: originalFilePath.includes("{") && originalFilePath.includes("=>"),
|
|
429
|
+
currentAuthor,
|
|
430
|
+
isEmpty: !currentAuthor
|
|
431
|
+
},
|
|
432
|
+
timestamp: Date.now(),
|
|
433
|
+
sessionId: "debug-session",
|
|
434
|
+
runId: "run1",
|
|
435
|
+
hypothesisId: "A"
|
|
436
|
+
})
|
|
437
|
+
}).catch(() => {
|
|
438
|
+
});
|
|
439
|
+
if (!filePath) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const linesChanged = insertions + deletions;
|
|
443
|
+
if (!fileAuthorLines[filePath]) {
|
|
444
|
+
fileAuthorLines[filePath] = {};
|
|
445
|
+
}
|
|
446
|
+
fileAuthorLines[filePath][currentAuthor] = (fileAuthorLines[filePath][currentAuthor] || 0) + linesChanged;
|
|
447
|
+
if (currentCommitDate) {
|
|
448
|
+
const existingDate = fileLastCommitDates[filePath];
|
|
449
|
+
if (!existingDate || currentCommitDate > existingDate) {
|
|
450
|
+
fileLastCommitDates[filePath] = currentCommitDate;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const fileOwnership = {};
|
|
457
|
+
const directoryOwnership = {};
|
|
458
|
+
const authorOwnership = {};
|
|
459
|
+
for (const [file, authorLines] of Object.entries(fileAuthorLines)) {
|
|
460
|
+
const totalLines = Object.values(authorLines).reduce((sum, lines2) => sum + lines2, 0);
|
|
461
|
+
let primaryOwner = "";
|
|
462
|
+
let maxLines = -1;
|
|
463
|
+
for (const [author, lines2] of Object.entries(authorLines)) {
|
|
464
|
+
if (!author || author.trim() === "") {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (lines2 > maxLines) {
|
|
468
|
+
maxLines = lines2;
|
|
469
|
+
primaryOwner = author;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (!primaryOwner) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
fetch("http://127.0.0.1:7242/ingest/f771081e-8bc3-4743-9457-44201273bd79", {
|
|
476
|
+
method: "POST",
|
|
477
|
+
headers: { "Content-Type": "application/json" },
|
|
478
|
+
body: JSON.stringify({
|
|
479
|
+
location: "ownership-log-parser.ts:77",
|
|
480
|
+
message: "Primary owner calculated",
|
|
481
|
+
data: {
|
|
482
|
+
file,
|
|
483
|
+
primaryOwner,
|
|
484
|
+
isEmpty: !primaryOwner,
|
|
485
|
+
hasRenameNotation: file.includes("{ => }"),
|
|
486
|
+
authorKeys: Object.keys(authorLines)
|
|
487
|
+
},
|
|
488
|
+
timestamp: Date.now(),
|
|
489
|
+
sessionId: "debug-session",
|
|
490
|
+
runId: "run1",
|
|
491
|
+
hypothesisId: "C"
|
|
492
|
+
})
|
|
493
|
+
}).catch(() => {
|
|
494
|
+
});
|
|
495
|
+
const share = totalLines > 0 ? Math.round(maxLines / totalLines * 100) : 0;
|
|
496
|
+
fileOwnership[file] = {
|
|
497
|
+
file,
|
|
498
|
+
owner: primaryOwner,
|
|
499
|
+
share,
|
|
500
|
+
ownerLines: maxLines,
|
|
501
|
+
totalLines,
|
|
502
|
+
authors: { ...authorLines },
|
|
503
|
+
lastCommitDate: fileLastCommitDates[file]
|
|
504
|
+
};
|
|
505
|
+
const directory = getDirectory(file);
|
|
506
|
+
fetch("http://127.0.0.1:7242/ingest/f771081e-8bc3-4743-9457-44201273bd79", {
|
|
507
|
+
method: "POST",
|
|
508
|
+
headers: { "Content-Type": "application/json" },
|
|
509
|
+
body: JSON.stringify({
|
|
510
|
+
location: "ownership-log-parser.ts:94",
|
|
511
|
+
message: "Directory extracted",
|
|
512
|
+
data: { file, directory, hasRenameNotation: directory.includes("{ => }") },
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
sessionId: "debug-session",
|
|
515
|
+
runId: "run1",
|
|
516
|
+
hypothesisId: "D"
|
|
517
|
+
})
|
|
518
|
+
}).catch(() => {
|
|
519
|
+
});
|
|
520
|
+
if (!directoryOwnership[directory]) {
|
|
521
|
+
directoryOwnership[directory] = {};
|
|
522
|
+
}
|
|
523
|
+
directoryOwnership[directory][file] = maxLines;
|
|
524
|
+
if (!authorOwnership[primaryOwner]) {
|
|
525
|
+
authorOwnership[primaryOwner] = {
|
|
526
|
+
author: primaryOwner,
|
|
527
|
+
files: [],
|
|
528
|
+
totalFiles: 0,
|
|
529
|
+
totalLines: 0
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
authorOwnership[primaryOwner].files.push({
|
|
533
|
+
file,
|
|
534
|
+
lines: maxLines,
|
|
535
|
+
share
|
|
536
|
+
});
|
|
537
|
+
authorOwnership[primaryOwner].totalFiles++;
|
|
538
|
+
authorOwnership[primaryOwner].totalLines += maxLines;
|
|
539
|
+
}
|
|
540
|
+
const directoryResult = {};
|
|
541
|
+
for (const [directory, files] of Object.entries(directoryOwnership)) {
|
|
542
|
+
const fileList = Object.keys(files);
|
|
543
|
+
const totalFiles = fileList.length;
|
|
544
|
+
const authorFileCounts = {};
|
|
545
|
+
for (const file of fileList) {
|
|
546
|
+
const ownership = fileOwnership[file];
|
|
547
|
+
if (ownership) {
|
|
548
|
+
authorFileCounts[ownership.owner] = (authorFileCounts[ownership.owner] || 0) + 1;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
let primaryOwner = "";
|
|
552
|
+
let maxFiles = 0;
|
|
553
|
+
for (const [author, count] of Object.entries(authorFileCounts)) {
|
|
554
|
+
if (!author || author.trim() === "") {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (count > maxFiles) {
|
|
558
|
+
maxFiles = count;
|
|
559
|
+
primaryOwner = author;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!primaryOwner) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
fetch("http://127.0.0.1:7242/ingest/f771081e-8bc3-4743-9457-44201273bd79", {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { "Content-Type": "application/json" },
|
|
568
|
+
body: JSON.stringify({
|
|
569
|
+
location: "ownership-log-parser.ts:140",
|
|
570
|
+
message: "Directory owner calculated",
|
|
571
|
+
data: {
|
|
572
|
+
directory,
|
|
573
|
+
primaryOwner,
|
|
574
|
+
isEmpty: !primaryOwner,
|
|
575
|
+
hasRenameNotation: directory.includes("{ => }"),
|
|
576
|
+
authorFileCounts
|
|
577
|
+
},
|
|
578
|
+
timestamp: Date.now(),
|
|
579
|
+
sessionId: "debug-session",
|
|
580
|
+
runId: "run1",
|
|
581
|
+
hypothesisId: "C"
|
|
582
|
+
})
|
|
583
|
+
}).catch(() => {
|
|
584
|
+
});
|
|
585
|
+
const share = totalFiles > 0 ? Math.round(maxFiles / totalFiles * 100) : 0;
|
|
586
|
+
const totalLines = fileList.reduce(
|
|
587
|
+
(sum, file) => sum + (fileOwnership[file]?.totalLines || 0),
|
|
588
|
+
0
|
|
589
|
+
);
|
|
590
|
+
directoryResult[directory] = {
|
|
591
|
+
directory,
|
|
592
|
+
owner: primaryOwner,
|
|
593
|
+
share,
|
|
594
|
+
ownerFiles: maxFiles,
|
|
595
|
+
totalFiles,
|
|
596
|
+
totalLines
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
for (const author of Object.values(authorOwnership)) {
|
|
600
|
+
author.files.sort((a, b) => b.lines - a.lines);
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
files: fileOwnership,
|
|
604
|
+
directories: directoryResult,
|
|
605
|
+
authors: authorOwnership
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function normalizeRenameNotation(filePath) {
|
|
609
|
+
const renamePattern = /\{[^}]*\s*=>\s*([^}]*)\}/g;
|
|
610
|
+
let result = filePath;
|
|
611
|
+
let hasEmptyRename = false;
|
|
612
|
+
result = result.replace(renamePattern, (_match, newPath) => {
|
|
613
|
+
const normalized = newPath.trim();
|
|
614
|
+
if (!normalized) {
|
|
615
|
+
hasEmptyRename = true;
|
|
616
|
+
return "";
|
|
617
|
+
}
|
|
618
|
+
return normalized;
|
|
619
|
+
});
|
|
620
|
+
if (hasEmptyRename && result.startsWith("/")) {
|
|
621
|
+
if (result === "/" || result.match(/^\/[^/]*$/)) {
|
|
622
|
+
return "";
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
function getDirectory(filePath) {
|
|
628
|
+
const lastSlashIndex = filePath.lastIndexOf("/");
|
|
629
|
+
if (lastSlashIndex === -1) {
|
|
630
|
+
return "./";
|
|
631
|
+
}
|
|
632
|
+
return filePath.substring(0, lastSlashIndex + 1);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/core/time/formatter.ts
|
|
636
|
+
function formatDateForGit(date) {
|
|
637
|
+
return date.toISOString().split("T")[0];
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// src/core/time/period-identifier.ts
|
|
641
|
+
function getWeekNumber(date) {
|
|
642
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
643
|
+
const dayNum = d.getUTCDay() || 7;
|
|
644
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
645
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
646
|
+
const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
647
|
+
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
|
|
648
|
+
}
|
|
649
|
+
function getMonthIdentifier(date) {
|
|
650
|
+
const year = date.getFullYear();
|
|
651
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
652
|
+
return `${year}-${month}`;
|
|
653
|
+
}
|
|
654
|
+
function getPeriodIdentifier(date, periodUnit) {
|
|
655
|
+
switch (periodUnit) {
|
|
656
|
+
case "daily":
|
|
657
|
+
return formatDateForGit(date);
|
|
658
|
+
case "weekly":
|
|
659
|
+
return getWeekNumber(date);
|
|
660
|
+
case "monthly":
|
|
661
|
+
return getMonthIdentifier(date);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/core/time/validator.ts
|
|
666
|
+
function validateTimeRange(since, until) {
|
|
667
|
+
if (since && until && since > until) {
|
|
668
|
+
throw new Error("--since date must be before --until date");
|
|
669
|
+
}
|
|
670
|
+
const now = /* @__PURE__ */ new Date();
|
|
671
|
+
if (since && since > now) {
|
|
672
|
+
throw new Error("--since date cannot be in the future");
|
|
673
|
+
}
|
|
674
|
+
if (until && until > now) {
|
|
675
|
+
throw new Error("--until date cannot be in the future");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/core/analysis/log-parsers/period-log-parser.ts
|
|
680
|
+
function parsePeriodStats(log, periodUnit, authors) {
|
|
681
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
682
|
+
const lines = log.split("\n");
|
|
683
|
+
let currentAuthor = "";
|
|
684
|
+
let currentPeriod = "";
|
|
685
|
+
const authorFilter = authors?.map((a) => a.toLowerCase());
|
|
686
|
+
for (const line of lines) {
|
|
687
|
+
const trimmedLine = line.trim();
|
|
688
|
+
if (trimmedLine.length === 0) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (trimmedLine.includes(" ")) {
|
|
692
|
+
const parts = trimmedLine.split(" ");
|
|
693
|
+
const insertions = parseInt(parts[0], 10) || 0;
|
|
694
|
+
const deletions = parseInt(parts[1], 10) || 0;
|
|
695
|
+
if (currentAuthor && currentPeriod) {
|
|
696
|
+
if (!grouped.has(currentPeriod)) {
|
|
697
|
+
grouped.set(currentPeriod, /* @__PURE__ */ new Map());
|
|
698
|
+
}
|
|
699
|
+
const periodMap = grouped.get(currentPeriod);
|
|
700
|
+
if (!periodMap.has(currentAuthor)) {
|
|
701
|
+
periodMap.set(currentAuthor, {
|
|
702
|
+
commits: 0,
|
|
703
|
+
insertions: 0,
|
|
704
|
+
deletions: 0,
|
|
705
|
+
filesTouched: 0
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
const stats = periodMap.get(currentAuthor);
|
|
709
|
+
stats.insertions += insertions;
|
|
710
|
+
stats.deletions += deletions;
|
|
711
|
+
stats.filesTouched++;
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
const parts = trimmedLine.split("|");
|
|
715
|
+
if (parts.length === 2) {
|
|
716
|
+
currentAuthor = parts[0];
|
|
717
|
+
const dateStr = parts[1];
|
|
718
|
+
const commitDate = new Date(dateStr);
|
|
719
|
+
if (authorFilter && !authorFilter.includes(currentAuthor.toLowerCase())) {
|
|
720
|
+
currentAuthor = "";
|
|
721
|
+
currentPeriod = "";
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
currentPeriod = getPeriodIdentifier(commitDate, periodUnit);
|
|
725
|
+
if (!grouped.has(currentPeriod)) {
|
|
726
|
+
grouped.set(currentPeriod, /* @__PURE__ */ new Map());
|
|
727
|
+
}
|
|
728
|
+
const periodMap = grouped.get(currentPeriod);
|
|
729
|
+
if (!periodMap.has(currentAuthor)) {
|
|
730
|
+
periodMap.set(currentAuthor, {
|
|
731
|
+
commits: 0,
|
|
732
|
+
insertions: 0,
|
|
733
|
+
deletions: 0,
|
|
734
|
+
filesTouched: 0
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
const stats = periodMap.get(currentAuthor);
|
|
738
|
+
stats.commits++;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return convertToSortedArray(grouped);
|
|
743
|
+
}
|
|
744
|
+
function convertToSortedArray(grouped) {
|
|
745
|
+
const result = [];
|
|
746
|
+
for (const [period, authorMap] of grouped.entries()) {
|
|
747
|
+
for (const [author, stats] of authorMap.entries()) {
|
|
748
|
+
result.push({ period, author, stats });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
result.sort((a, b) => {
|
|
752
|
+
if (a.period !== b.period) {
|
|
753
|
+
return b.period.localeCompare(a.period);
|
|
754
|
+
}
|
|
755
|
+
const impactA = a.stats.insertions + a.stats.deletions;
|
|
756
|
+
const impactB = b.stats.insertions + b.stats.deletions;
|
|
757
|
+
return impactB - impactA;
|
|
758
|
+
});
|
|
759
|
+
return result;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/core/constants/analysis-mode.constants.ts
|
|
763
|
+
var DEFAULT_MODE = "aggregate";
|
|
764
|
+
var VALID_MODES = ["aggregate", "periodic", "ownership"];
|
|
765
|
+
|
|
766
|
+
// src/core/constants/efficiency-thresholds.constants.ts
|
|
767
|
+
var EFFICIENCY_THRESHOLDS = {
|
|
768
|
+
/** Below this is considered "Micro Commits" */
|
|
769
|
+
MICRO: 10,
|
|
770
|
+
/** Good range lower bound */
|
|
771
|
+
OPTIMAL_MIN: 30,
|
|
772
|
+
/** Good range upper bound */
|
|
773
|
+
OPTIMAL_MAX: 150,
|
|
774
|
+
/** Above this is considered "Huge" */
|
|
775
|
+
HUGE: 500
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
// src/core/constants/period-unit.constants.ts
|
|
779
|
+
var VALID_PERIOD_UNITS = ["daily", "weekly", "monthly"];
|
|
780
|
+
var DEFAULT_PERIOD_UNIT = "daily";
|
|
781
|
+
|
|
782
|
+
// src/core/constants/index.ts
|
|
783
|
+
var DEFAULT_DAYS = 30;
|
|
784
|
+
|
|
785
|
+
// src/core/analysis/metrics/efficiency-metric.ts
|
|
786
|
+
function getEfficiencyLabel(efficiency) {
|
|
787
|
+
if (efficiency < EFFICIENCY_THRESHOLDS.MICRO) {
|
|
788
|
+
return "\u{1F7E1} Micro";
|
|
789
|
+
}
|
|
790
|
+
if (efficiency >= EFFICIENCY_THRESHOLDS.OPTIMAL_MIN && efficiency <= EFFICIENCY_THRESHOLDS.OPTIMAL_MAX) {
|
|
791
|
+
return "\u2705 Optimal";
|
|
792
|
+
}
|
|
793
|
+
if (efficiency > EFFICIENCY_THRESHOLDS.HUGE) {
|
|
794
|
+
return "\u{1F6A8} Huge";
|
|
795
|
+
}
|
|
796
|
+
if (efficiency > EFFICIENCY_THRESHOLDS.OPTIMAL_MAX) {
|
|
797
|
+
return "\u26A0\uFE0F High Load";
|
|
798
|
+
}
|
|
799
|
+
return "\u{1F7E1} Small";
|
|
800
|
+
}
|
|
801
|
+
function classifyCommitSize(linesChanged) {
|
|
802
|
+
if (linesChanged <= EFFICIENCY_THRESHOLDS.MICRO) {
|
|
803
|
+
return "micro";
|
|
804
|
+
}
|
|
805
|
+
if (linesChanged < EFFICIENCY_THRESHOLDS.OPTIMAL_MIN) {
|
|
806
|
+
return "small";
|
|
807
|
+
}
|
|
808
|
+
if (linesChanged <= EFFICIENCY_THRESHOLDS.OPTIMAL_MAX) {
|
|
809
|
+
return "optimal";
|
|
810
|
+
}
|
|
811
|
+
if (linesChanged <= EFFICIENCY_THRESHOLDS.HUGE) {
|
|
812
|
+
return "high";
|
|
813
|
+
}
|
|
814
|
+
return "huge";
|
|
815
|
+
}
|
|
816
|
+
function analyzeAuthorEfficiency(authorName, commits) {
|
|
817
|
+
const authorCommits = commits.filter((c) => c.author === authorName);
|
|
818
|
+
const distribution = {
|
|
819
|
+
micro: 0,
|
|
820
|
+
small: 0,
|
|
821
|
+
optimal: 0,
|
|
822
|
+
high: 0,
|
|
823
|
+
huge: 0
|
|
824
|
+
};
|
|
825
|
+
if (authorCommits.length === 0) {
|
|
826
|
+
return {
|
|
827
|
+
author: authorName,
|
|
828
|
+
efficiency: 0,
|
|
829
|
+
efficiencyLabel: getEfficiencyLabel(0),
|
|
830
|
+
distribution,
|
|
831
|
+
totalCommits: 0
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
let totalDelta = 0;
|
|
835
|
+
for (const commit of authorCommits) {
|
|
836
|
+
const linesChanged = commit.insertions + commit.deletions;
|
|
837
|
+
totalDelta += linesChanged;
|
|
838
|
+
const bucket = classifyCommitSize(linesChanged);
|
|
839
|
+
distribution[bucket]++;
|
|
840
|
+
}
|
|
841
|
+
const efficiency = Math.round(totalDelta / authorCommits.length);
|
|
842
|
+
return {
|
|
843
|
+
author: authorName,
|
|
844
|
+
efficiency,
|
|
845
|
+
efficiencyLabel: getEfficiencyLabel(efficiency),
|
|
846
|
+
distribution,
|
|
847
|
+
totalCommits: authorCommits.length
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function analyzeAllEfficiency(commits) {
|
|
851
|
+
const authors = /* @__PURE__ */ new Set();
|
|
852
|
+
for (const commit of commits) {
|
|
853
|
+
authors.add(commit.author);
|
|
854
|
+
}
|
|
855
|
+
const result = {};
|
|
856
|
+
for (const author of authors) {
|
|
857
|
+
result[author] = analyzeAuthorEfficiency(author, commits);
|
|
858
|
+
}
|
|
859
|
+
return result;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// src/core/config/query-builder.ts
|
|
863
|
+
function buildLogQuery(opts) {
|
|
864
|
+
const query = {
|
|
865
|
+
branch: opts.branch
|
|
866
|
+
};
|
|
867
|
+
if (opts.timeRange.since || opts.timeRange.until || opts.timeRange.days) {
|
|
868
|
+
if (opts.timeRange.since) {
|
|
869
|
+
validateTimeRange(opts.timeRange.since, opts.timeRange.until);
|
|
870
|
+
query.since = formatDateForGit(opts.timeRange.since);
|
|
871
|
+
} else if (opts.timeRange.days) {
|
|
872
|
+
query.since = `${opts.timeRange.days} days ago`;
|
|
873
|
+
}
|
|
874
|
+
if (opts.timeRange.until) {
|
|
875
|
+
query.until = formatDateForGit(opts.timeRange.until);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return query;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/core/config/resolver.ts
|
|
882
|
+
function resolveOptions(options) {
|
|
883
|
+
return {
|
|
884
|
+
timeRange: {
|
|
885
|
+
...{ days: DEFAULT_DAYS },
|
|
886
|
+
...options.timeRange
|
|
887
|
+
},
|
|
888
|
+
mode: options.mode ?? DEFAULT_MODE,
|
|
889
|
+
periodUnit: options.periodUnit ?? DEFAULT_PERIOD_UNIT,
|
|
890
|
+
branch: options.branch
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/core/analysis/modes/aggregate-mode.ts
|
|
895
|
+
var AggregateMode = class {
|
|
896
|
+
async handle(options, gitClient, originalOptions) {
|
|
897
|
+
const { since, until, branch } = buildLogQuery(options);
|
|
898
|
+
let aggregateLog = "";
|
|
899
|
+
let efficiencyLog = "";
|
|
900
|
+
try {
|
|
901
|
+
[aggregateLog, efficiencyLog] = await Promise.all([
|
|
902
|
+
gitClient.getAggregateLog(since, until, branch),
|
|
903
|
+
gitClient.getStabilityLog(since, until, branch)
|
|
904
|
+
]);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
if (error instanceof Error && error.message.includes("does not have any commits")) {
|
|
907
|
+
return {
|
|
908
|
+
stats: {},
|
|
909
|
+
efficiency: {}
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
const stats = parseAggregateStats(aggregateLog, originalOptions.authors);
|
|
915
|
+
const commits = parseEfficiencyLog(efficiencyLog);
|
|
916
|
+
const authorFilter = originalOptions.authors?.map((a) => a.toLowerCase());
|
|
917
|
+
const filteredCommits = authorFilter ? commits.filter((c) => authorFilter.includes(c.author.toLowerCase())) : commits;
|
|
918
|
+
const efficiency = analyzeAllEfficiency(filteredCommits);
|
|
919
|
+
return {
|
|
920
|
+
stats,
|
|
921
|
+
efficiency
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// src/core/analysis/modes/ownership-mode.ts
|
|
927
|
+
var OwnershipMode = class {
|
|
928
|
+
async handle(options, gitClient, originalOptions) {
|
|
929
|
+
const { since, until, branch } = buildLogQuery(options);
|
|
930
|
+
let fileAuthorLog = "";
|
|
931
|
+
try {
|
|
932
|
+
fileAuthorLog = await gitClient.getFileAuthorLog(
|
|
933
|
+
since,
|
|
934
|
+
until,
|
|
935
|
+
branch,
|
|
936
|
+
originalOptions.directory
|
|
937
|
+
);
|
|
938
|
+
} catch (error) {
|
|
939
|
+
if (error instanceof Error && error.message.includes("does not have any commits")) {
|
|
940
|
+
return {
|
|
941
|
+
files: {},
|
|
942
|
+
directories: {},
|
|
943
|
+
authors: {}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
const result = parseOwnershipStats(fileAuthorLog, originalOptions.authors);
|
|
949
|
+
return result;
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
// src/core/analysis/modes/periodic-mode.ts
|
|
954
|
+
var PeriodicMode = class {
|
|
955
|
+
async handle(options, gitClient, originalOptions) {
|
|
956
|
+
const { since, until, branch } = buildLogQuery(options);
|
|
957
|
+
let log = "";
|
|
958
|
+
try {
|
|
959
|
+
log = await gitClient.getPeriodicLog(since, until, branch);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
if (error instanceof Error && error.message.includes("does not have any commits")) {
|
|
962
|
+
return [];
|
|
963
|
+
}
|
|
964
|
+
throw error;
|
|
965
|
+
}
|
|
966
|
+
return parsePeriodStats(log, options.periodUnit, originalOptions.authors);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
// src/core/analysis/modes/mode-registry.ts
|
|
971
|
+
var modeRegistry = /* @__PURE__ */ new Map([
|
|
972
|
+
["aggregate", new AggregateMode()],
|
|
973
|
+
["periodic", new PeriodicMode()],
|
|
974
|
+
["ownership", new OwnershipMode()]
|
|
975
|
+
]);
|
|
976
|
+
|
|
977
|
+
// src/core/git/client.ts
|
|
978
|
+
import { simpleGit as simpleGit2 } from "simple-git";
|
|
979
|
+
var GitClientImpl = class {
|
|
980
|
+
git;
|
|
981
|
+
constructor(git) {
|
|
982
|
+
this.git = git ?? simpleGit2();
|
|
983
|
+
}
|
|
984
|
+
async isRepository() {
|
|
985
|
+
return this.git.checkIsRepo();
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Get aggregate stats log (author name only)
|
|
989
|
+
* Used for total contribution analysis
|
|
990
|
+
*/
|
|
991
|
+
async getAggregateLog(since, until, branch) {
|
|
992
|
+
const args = ["log", "--numstat", "--pretty=format:%aN"];
|
|
993
|
+
if (branch) args.push(branch);
|
|
994
|
+
if (since) args.push(`--since=${since}`);
|
|
995
|
+
if (until) args.push(`--until=${until}`);
|
|
996
|
+
return this.git.raw(args);
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Get periodic stats log (author name with commit date)
|
|
1000
|
+
* Used for time-based grouped analysis
|
|
1001
|
+
*/
|
|
1002
|
+
async getPeriodicLog(since, until, branch) {
|
|
1003
|
+
const args = ["log", "--numstat", "--pretty=format:%aN|%cd", "--date=iso"];
|
|
1004
|
+
if (branch) args.push(branch);
|
|
1005
|
+
if (since) args.push(`--since=${since}`);
|
|
1006
|
+
if (until) args.push(`--until=${until}`);
|
|
1007
|
+
return this.git.raw(args);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Get stability analysis log (author name with commit date and file changes)
|
|
1011
|
+
* Used for efficiency and churn rate analysis
|
|
1012
|
+
* Format: author|date followed by numstat lines
|
|
1013
|
+
*/
|
|
1014
|
+
async getStabilityLog(since, until, branch) {
|
|
1015
|
+
const args = ["log", "--numstat", "--pretty=format:%aN|%cd", "--date=iso", "--reverse"];
|
|
1016
|
+
if (branch) args.push(branch);
|
|
1017
|
+
if (since) args.push(`--since=${since}`);
|
|
1018
|
+
if (until) args.push(`--until=${until}`);
|
|
1019
|
+
return this.git.raw(args);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Get file-author log with numstat (author name followed by file changes with line counts)
|
|
1023
|
+
* Used for ownership analysis
|
|
1024
|
+
* Format: AUTHOR:<name>|DATE:<date> followed by numstat lines (insertions\tdeletions\tfilepath)
|
|
1025
|
+
* The AUTHOR: prefix ensures author names are not confused with file paths
|
|
1026
|
+
*/
|
|
1027
|
+
async getFileAuthorLog(since, until, branch, directory) {
|
|
1028
|
+
const args = ["log", "--numstat", "--pretty=format:AUTHOR:%aN|DATE:%cd", "--date=iso"];
|
|
1029
|
+
if (branch) args.push(branch);
|
|
1030
|
+
if (since) {
|
|
1031
|
+
if (since.includes("days ago") || since.includes("day ago")) {
|
|
1032
|
+
args.push(`--since=${since}`);
|
|
1033
|
+
} else {
|
|
1034
|
+
args.push(`--since=${since}`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (until) {
|
|
1038
|
+
if (until.includes("days ago") || until.includes("day ago")) {
|
|
1039
|
+
args.push(`--until=${until}`);
|
|
1040
|
+
} else {
|
|
1041
|
+
args.push(`--until=${until}`);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (directory) {
|
|
1045
|
+
args.push("--");
|
|
1046
|
+
args.push(directory);
|
|
1047
|
+
}
|
|
1048
|
+
return this.git.raw(args);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Get commit-files log (commit hash followed by file paths)
|
|
1052
|
+
* Used for temporal coupling analysis (files changed together)
|
|
1053
|
+
* Format: COMMIT:<hash> followed by file paths (one per line)
|
|
1054
|
+
*/
|
|
1055
|
+
async getCommitFilesLog(since, until, branch) {
|
|
1056
|
+
const args = ["log", "--name-only", "--pretty=format:COMMIT:%H"];
|
|
1057
|
+
if (branch) args.push(branch);
|
|
1058
|
+
if (since) args.push(`--since=${since}`);
|
|
1059
|
+
if (until) args.push(`--until=${until}`);
|
|
1060
|
+
return this.git.raw(args);
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// src/core/git/client-factory.ts
|
|
1065
|
+
function createGitClient(git) {
|
|
1066
|
+
return new GitClientImpl(git);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// src/core/analysis/orchestrator.ts
|
|
1070
|
+
async function analyzeContributions(options = {}, gitClient) {
|
|
1071
|
+
const client = gitClient ?? createGitClient();
|
|
1072
|
+
const isRepo = await client.isRepository();
|
|
1073
|
+
if (!isRepo) {
|
|
1074
|
+
throw new Error("Not a git repository. Please run this command in a git repository.");
|
|
1075
|
+
}
|
|
1076
|
+
const resolvedOpts = resolveOptions(options);
|
|
1077
|
+
const mode = modeRegistry.get(resolvedOpts.mode);
|
|
1078
|
+
if (!mode) {
|
|
1079
|
+
throw new Error(`Unknown analysis mode: ${resolvedOpts.mode}`);
|
|
1080
|
+
}
|
|
1081
|
+
let result = await mode.handle(resolvedOpts, client, {
|
|
1082
|
+
authors: options.authors,
|
|
1083
|
+
directory: options.directory
|
|
1084
|
+
});
|
|
1085
|
+
if (resolvedOpts.mode === "ownership") {
|
|
1086
|
+
const filePatternFilter = new FilePatternFilter(
|
|
1087
|
+
options.excludePatterns,
|
|
1088
|
+
options.respectGitignore !== false,
|
|
1089
|
+
// default to true
|
|
1090
|
+
process.cwd(),
|
|
1091
|
+
void 0
|
|
1092
|
+
// Don't filter by directory here since it's already filtered at Git log level
|
|
1093
|
+
);
|
|
1094
|
+
result = await filePatternFilter.filter(result);
|
|
1095
|
+
} else {
|
|
1096
|
+
const filterChain = new FilterChain();
|
|
1097
|
+
if (options.minCommits !== void 0 && options.minCommits > 1) {
|
|
1098
|
+
filterChain.addFilter(new MinCommitsFilter(options.minCommits));
|
|
1099
|
+
}
|
|
1100
|
+
if (!filterChain.isEmpty()) {
|
|
1101
|
+
result = filterChain.apply(result);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return result;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// src/cli/commands/command-builder.ts
|
|
1108
|
+
import { Command } from "commander";
|
|
1109
|
+
function addCommonOptions(command) {
|
|
1110
|
+
return command.option(
|
|
1111
|
+
"-d, --days <number>",
|
|
1112
|
+
`number of days to analyze (default: ${DEFAULT_DAYS})`,
|
|
1113
|
+
(val) => parseInt(val, 10)
|
|
1114
|
+
).option("-s, --since <date>", "start date (YYYY-MM-DD)", (val) => new Date(val)).option("-u, --until <date>", "end date (YYYY-MM-DD)", (val) => new Date(val)).option(
|
|
1115
|
+
"-a, --authors <names...>",
|
|
1116
|
+
"filter by specific author names (can specify multiple). Enables deep-dive stability analysis"
|
|
1117
|
+
).option("-b, --branch <name>", "analyze specific branch (default: current branch)").option(
|
|
1118
|
+
"-c, --min-commits <number>",
|
|
1119
|
+
"minimum number of commits to include an author (default: 1)",
|
|
1120
|
+
(val) => parseInt(val, 10)
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
function createCommand() {
|
|
1124
|
+
const program = new Command();
|
|
1125
|
+
program.name("gimpact").description("Visualize Git contribution impact by developer").version("0.1.0").option(
|
|
1126
|
+
"-d, --days <number>",
|
|
1127
|
+
`number of days to analyze (default: ${DEFAULT_DAYS})`,
|
|
1128
|
+
(val) => parseInt(val, 10)
|
|
1129
|
+
).option("-s, --since <date>", "start date (YYYY-MM-DD)", (val) => new Date(val)).option("-u, --until <date>", "end date (YYYY-MM-DD)", (val) => new Date(val)).option(
|
|
1130
|
+
"-m, --mode <type>",
|
|
1131
|
+
`analysis mode: ${VALID_MODES.join(", ")} (default: aggregate)`,
|
|
1132
|
+
"aggregate"
|
|
1133
|
+
).option(
|
|
1134
|
+
"-p, --period-unit <type>",
|
|
1135
|
+
`period unit for periodic mode: ${VALID_PERIOD_UNITS.join(", ")} (default: daily)`,
|
|
1136
|
+
"daily"
|
|
1137
|
+
).option(
|
|
1138
|
+
"-a, --authors <names...>",
|
|
1139
|
+
"filter by specific author names (can specify multiple). Enables deep-dive stability analysis"
|
|
1140
|
+
).option("-b, --branch <name>", "analyze specific branch (default: current branch)").option(
|
|
1141
|
+
"-c, --min-commits <number>",
|
|
1142
|
+
"minimum number of commits to include an author (default: 1)",
|
|
1143
|
+
(val) => parseInt(val, 10)
|
|
1144
|
+
).action(() => {
|
|
1145
|
+
});
|
|
1146
|
+
addCommonOptions(
|
|
1147
|
+
program.command("summary").description("Show aggregate report with all analyses (default)")
|
|
1148
|
+
).action(() => {
|
|
1149
|
+
});
|
|
1150
|
+
addCommonOptions(program.command("daily").description("Show daily periodic report")).action(
|
|
1151
|
+
() => {
|
|
1152
|
+
}
|
|
1153
|
+
);
|
|
1154
|
+
addCommonOptions(program.command("weekly").description("Show weekly periodic report")).action(
|
|
1155
|
+
() => {
|
|
1156
|
+
}
|
|
1157
|
+
);
|
|
1158
|
+
addCommonOptions(program.command("monthly").description("Show monthly periodic report")).action(
|
|
1159
|
+
() => {
|
|
1160
|
+
}
|
|
1161
|
+
);
|
|
1162
|
+
addCommonOptions(
|
|
1163
|
+
program.command("ownership").description("Show code ownership analysis (files, directories, authors)").option(
|
|
1164
|
+
"--exclude-patterns <patterns...>",
|
|
1165
|
+
'additional glob patterns to exclude from analysis (e.g., "**/*.lock" "**/dist/**")'
|
|
1166
|
+
).option(
|
|
1167
|
+
"--no-respect-gitignore",
|
|
1168
|
+
"include files that are ignored by .gitignore (default: respect .gitignore)"
|
|
1169
|
+
).option(
|
|
1170
|
+
"--directory <path>",
|
|
1171
|
+
'filter by directory path (only include files in this directory, e.g., "packages/cli/src")'
|
|
1172
|
+
)
|
|
1173
|
+
).action(() => {
|
|
1174
|
+
});
|
|
1175
|
+
return program;
|
|
1176
|
+
}
|
|
1177
|
+
function parseArgs(program) {
|
|
1178
|
+
program.parse(process.argv);
|
|
1179
|
+
const args = program.args;
|
|
1180
|
+
let subcommand = "summary";
|
|
1181
|
+
if (args.length > 0 && args[0]) {
|
|
1182
|
+
const validSubcommands = [
|
|
1183
|
+
"summary",
|
|
1184
|
+
"daily",
|
|
1185
|
+
"weekly",
|
|
1186
|
+
"monthly",
|
|
1187
|
+
"ownership"
|
|
1188
|
+
];
|
|
1189
|
+
if (validSubcommands.includes(args[0])) {
|
|
1190
|
+
subcommand = args[0];
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
const subcommandCommand = subcommand !== "summary" ? program.commands.find((cmd) => cmd.name() === subcommand) : null;
|
|
1194
|
+
const mainOpts = program.opts();
|
|
1195
|
+
const subOpts = subcommandCommand ? subcommandCommand.opts() : {};
|
|
1196
|
+
const opts = { ...mainOpts, ...subOpts };
|
|
1197
|
+
return {
|
|
1198
|
+
subcommand,
|
|
1199
|
+
days: opts.days,
|
|
1200
|
+
since: opts.since,
|
|
1201
|
+
until: opts.until,
|
|
1202
|
+
mode: opts.mode,
|
|
1203
|
+
periodUnit: opts.periodUnit,
|
|
1204
|
+
authors: opts.authors,
|
|
1205
|
+
branch: opts.branch,
|
|
1206
|
+
minCommits: opts.minCommits,
|
|
1207
|
+
excludePatterns: opts.excludePatterns,
|
|
1208
|
+
respectGitignore: opts.respectGitignore,
|
|
1209
|
+
directory: opts.directory
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/cli/commands/subcommand-registry.ts
|
|
1214
|
+
var subcommandToMode = {
|
|
1215
|
+
summary: { mode: "aggregate" },
|
|
1216
|
+
daily: { mode: "periodic", periodUnit: "daily" },
|
|
1217
|
+
weekly: { mode: "periodic", periodUnit: "weekly" },
|
|
1218
|
+
monthly: { mode: "periodic", periodUnit: "monthly" },
|
|
1219
|
+
ownership: { mode: "ownership" }
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
// src/cli/adapters/options-adapter.ts
|
|
1223
|
+
function adaptToAnalyzerOptions(options) {
|
|
1224
|
+
const subcommandConfig = subcommandToMode[options.subcommand];
|
|
1225
|
+
const mode = subcommandConfig.mode;
|
|
1226
|
+
const periodUnit = subcommandConfig.periodUnit ?? options.periodUnit;
|
|
1227
|
+
const analyzerOptions = {
|
|
1228
|
+
timeRange: {},
|
|
1229
|
+
mode,
|
|
1230
|
+
periodUnit,
|
|
1231
|
+
branch: options.branch
|
|
1232
|
+
};
|
|
1233
|
+
if (options.authors && options.authors.length > 0) {
|
|
1234
|
+
analyzerOptions.authors = options.authors;
|
|
1235
|
+
}
|
|
1236
|
+
if (options.since || options.until) {
|
|
1237
|
+
if (options.since) {
|
|
1238
|
+
analyzerOptions.timeRange.since = options.since;
|
|
1239
|
+
}
|
|
1240
|
+
if (options.until) {
|
|
1241
|
+
analyzerOptions.timeRange.until = options.until;
|
|
1242
|
+
}
|
|
1243
|
+
} else if (options.days) {
|
|
1244
|
+
analyzerOptions.timeRange.days = options.days;
|
|
1245
|
+
} else {
|
|
1246
|
+
analyzerOptions.timeRange.days = DEFAULT_DAYS;
|
|
1247
|
+
}
|
|
1248
|
+
if (options.minCommits !== void 0) {
|
|
1249
|
+
analyzerOptions.minCommits = options.minCommits;
|
|
1250
|
+
}
|
|
1251
|
+
if (options.excludePatterns && options.excludePatterns.length > 0) {
|
|
1252
|
+
analyzerOptions.excludePatterns = options.excludePatterns;
|
|
1253
|
+
}
|
|
1254
|
+
if (options.respectGitignore !== void 0) {
|
|
1255
|
+
analyzerOptions.respectGitignore = options.respectGitignore;
|
|
1256
|
+
}
|
|
1257
|
+
if (options.directory) {
|
|
1258
|
+
analyzerOptions.directory = options.directory;
|
|
1259
|
+
}
|
|
1260
|
+
return analyzerOptions;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/cli/adapters/time-range-formatter.ts
|
|
1264
|
+
function buildTimeRangeDescription(options) {
|
|
1265
|
+
if (options.since && options.until) {
|
|
1266
|
+
return `${options.since.toISOString().split("T")[0]} to ${options.until.toISOString().split("T")[0]}`;
|
|
1267
|
+
}
|
|
1268
|
+
if (options.since) {
|
|
1269
|
+
return `Since ${options.since.toISOString().split("T")[0]}`;
|
|
1270
|
+
}
|
|
1271
|
+
if (options.until) {
|
|
1272
|
+
return `Until ${options.until.toISOString().split("T")[0]}`;
|
|
1273
|
+
}
|
|
1274
|
+
const days = options.days ?? DEFAULT_DAYS;
|
|
1275
|
+
return `Last ${days} day${days === 1 ? "" : "s"}`;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/cli/output/components/bar-renderer.ts
|
|
1279
|
+
function renderBar(count, maxCount, maxWidth) {
|
|
1280
|
+
if (maxCount === 0) return "";
|
|
1281
|
+
const width = Math.round(count / maxCount * maxWidth);
|
|
1282
|
+
return "\u2593".repeat(width);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/cli/output/components/summary-row.ts
|
|
1286
|
+
import chalk from "chalk";
|
|
1287
|
+
function createSummaryRow(data) {
|
|
1288
|
+
const row = [
|
|
1289
|
+
chalk.bold(`\u{1F4CA} Total (${data.timeRangeDescription})`),
|
|
1290
|
+
chalk.bold(chalk.cyan(data.totalCommits.toString())),
|
|
1291
|
+
chalk.bold(chalk.green(`+${data.totalInsertions.toLocaleString()}`)),
|
|
1292
|
+
chalk.bold(chalk.red(`-${data.totalDeletions.toLocaleString()}`))
|
|
1293
|
+
];
|
|
1294
|
+
if (data.totalFiles !== void 0) {
|
|
1295
|
+
row.push(chalk.bold(chalk.blue(data.totalFiles.toString())));
|
|
1296
|
+
}
|
|
1297
|
+
if (data.additionalInfo) {
|
|
1298
|
+
row.push(chalk.bold(chalk.yellow(data.additionalInfo)));
|
|
1299
|
+
}
|
|
1300
|
+
return row;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// src/cli/output/components/table-builder.ts
|
|
1304
|
+
import Table from "cli-table3";
|
|
1305
|
+
function createTable(config) {
|
|
1306
|
+
return new Table({
|
|
1307
|
+
head: config.head,
|
|
1308
|
+
colAligns: config.colAligns || [],
|
|
1309
|
+
style: {
|
|
1310
|
+
head: ["cyan"],
|
|
1311
|
+
border: ["gray"]
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// src/cli/output/printers/aggregate-printer.ts
|
|
1317
|
+
import chalk2 from "chalk";
|
|
1318
|
+
var AggregatePrinter = class {
|
|
1319
|
+
print(result, timeRangeDescription) {
|
|
1320
|
+
this.printAggregateStats(result.stats, timeRangeDescription);
|
|
1321
|
+
this.printEfficiencyAnalysis(result.efficiency);
|
|
1322
|
+
}
|
|
1323
|
+
printAggregateStats(stats, timeRangeDescription) {
|
|
1324
|
+
const sortedAuthors = Object.entries(stats).sort((a, b) => {
|
|
1325
|
+
const impactA = a[1].insertions + a[1].deletions;
|
|
1326
|
+
const impactB = b[1].insertions + b[1].deletions;
|
|
1327
|
+
return impactB - impactA;
|
|
1328
|
+
});
|
|
1329
|
+
if (sortedAuthors.length === 0) {
|
|
1330
|
+
console.log(chalk2.yellow(`
|
|
1331
|
+
\u26A0 No commits found in the specified time range.
|
|
1332
|
+
`));
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const table = createTable({
|
|
1336
|
+
head: [
|
|
1337
|
+
chalk2.bold("Author"),
|
|
1338
|
+
chalk2.bold("Commits"),
|
|
1339
|
+
chalk2.bold("Insertions"),
|
|
1340
|
+
chalk2.bold("Deletions"),
|
|
1341
|
+
chalk2.bold("Files")
|
|
1342
|
+
],
|
|
1343
|
+
colAligns: ["left", "right", "right", "right", "right"]
|
|
1344
|
+
});
|
|
1345
|
+
const totalCommits = sortedAuthors.reduce((sum, [, data]) => sum + data.commits, 0);
|
|
1346
|
+
const totalInsertions = sortedAuthors.reduce((sum, [, data]) => sum + data.insertions, 0);
|
|
1347
|
+
const totalDeletions = sortedAuthors.reduce((sum, [, data]) => sum + data.deletions, 0);
|
|
1348
|
+
const totalFiles = sortedAuthors.reduce((sum, [, data]) => sum + data.filesTouched, 0);
|
|
1349
|
+
for (const [author, data] of sortedAuthors) {
|
|
1350
|
+
table.push([
|
|
1351
|
+
author,
|
|
1352
|
+
chalk2.white(data.commits.toString()),
|
|
1353
|
+
chalk2.green(`+${data.insertions.toLocaleString()}`),
|
|
1354
|
+
chalk2.red(`-${data.deletions.toLocaleString()}`),
|
|
1355
|
+
chalk2.blue(data.filesTouched.toString())
|
|
1356
|
+
]);
|
|
1357
|
+
}
|
|
1358
|
+
table.push(
|
|
1359
|
+
createSummaryRow({
|
|
1360
|
+
totalCommits,
|
|
1361
|
+
totalInsertions,
|
|
1362
|
+
totalDeletions,
|
|
1363
|
+
totalFiles,
|
|
1364
|
+
timeRangeDescription
|
|
1365
|
+
})
|
|
1366
|
+
);
|
|
1367
|
+
console.log(`
|
|
1368
|
+
${table.toString()}
|
|
1369
|
+
`);
|
|
1370
|
+
}
|
|
1371
|
+
printEfficiencyAnalysis(efficiency) {
|
|
1372
|
+
const authors = Object.keys(efficiency);
|
|
1373
|
+
if (authors.length === 0) {
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const totalDist = {
|
|
1377
|
+
micro: 0,
|
|
1378
|
+
small: 0,
|
|
1379
|
+
optimal: 0,
|
|
1380
|
+
high: 0,
|
|
1381
|
+
huge: 0
|
|
1382
|
+
};
|
|
1383
|
+
let totalCommits = 0;
|
|
1384
|
+
let totalLines = 0;
|
|
1385
|
+
for (const author of authors) {
|
|
1386
|
+
const stats = efficiency[author];
|
|
1387
|
+
totalDist.micro += stats.distribution.micro;
|
|
1388
|
+
totalDist.small += stats.distribution.small;
|
|
1389
|
+
totalDist.optimal += stats.distribution.optimal;
|
|
1390
|
+
totalDist.high += stats.distribution.high;
|
|
1391
|
+
totalDist.huge += stats.distribution.huge;
|
|
1392
|
+
totalCommits += stats.totalCommits;
|
|
1393
|
+
totalLines += stats.efficiency * stats.totalCommits;
|
|
1394
|
+
}
|
|
1395
|
+
const avgEfficiency = totalCommits > 0 ? Math.round(totalLines / totalCommits) : 0;
|
|
1396
|
+
console.log(chalk2.bold.cyan("\u26A1 Commit Size Distribution (All Authors)"));
|
|
1397
|
+
console.log(chalk2.dim(` Average: ${avgEfficiency} lines/commit`));
|
|
1398
|
+
console.log(chalk2.dim(` Total: ${totalCommits} commits
|
|
1399
|
+
`));
|
|
1400
|
+
const maxCount = Math.max(
|
|
1401
|
+
totalDist.micro,
|
|
1402
|
+
totalDist.small,
|
|
1403
|
+
totalDist.optimal,
|
|
1404
|
+
totalDist.high,
|
|
1405
|
+
totalDist.huge
|
|
1406
|
+
);
|
|
1407
|
+
const maxBarWidth = 30;
|
|
1408
|
+
const buckets = [
|
|
1409
|
+
{
|
|
1410
|
+
label: "Micro",
|
|
1411
|
+
range: "0-10",
|
|
1412
|
+
count: totalDist.micro,
|
|
1413
|
+
indicator: "\u{1F7E1}",
|
|
1414
|
+
color: chalk2.yellow
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
label: "Small",
|
|
1418
|
+
range: "11-30",
|
|
1419
|
+
count: totalDist.small,
|
|
1420
|
+
indicator: "\u{1F7E1}",
|
|
1421
|
+
color: chalk2.yellow
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
label: "Optimal",
|
|
1425
|
+
range: "31-150",
|
|
1426
|
+
count: totalDist.optimal,
|
|
1427
|
+
indicator: "\u2705",
|
|
1428
|
+
color: chalk2.green
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
label: "High",
|
|
1432
|
+
range: "151-500",
|
|
1433
|
+
count: totalDist.high,
|
|
1434
|
+
indicator: "\u26A0\uFE0F",
|
|
1435
|
+
color: chalk2.hex("#FFA500")
|
|
1436
|
+
},
|
|
1437
|
+
{ label: "Huge", range: "500+", count: totalDist.huge, indicator: "\u{1F6A8}", color: chalk2.red }
|
|
1438
|
+
];
|
|
1439
|
+
for (const bucket of buckets) {
|
|
1440
|
+
const bar = renderBar(bucket.count, maxCount, maxBarWidth);
|
|
1441
|
+
const paddedRange = bucket.range.padStart(7);
|
|
1442
|
+
const countStr = bucket.count.toString().padStart(3);
|
|
1443
|
+
console.log(
|
|
1444
|
+
chalk2.gray(` ${paddedRange} `) + bucket.color(bar.padEnd(maxBarWidth)) + chalk2.gray(` ${countStr} `) + bucket.indicator
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
console.log("");
|
|
1448
|
+
}
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
// src/cli/output/printers/ownership-printer.ts
|
|
1452
|
+
import chalk5 from "chalk";
|
|
1453
|
+
|
|
1454
|
+
// src/cli/output/utils/ownership-formatter.ts
|
|
1455
|
+
import chalk4 from "chalk";
|
|
1456
|
+
|
|
1457
|
+
// src/cli/output/utils/tree-formatter.ts
|
|
1458
|
+
import chalk3 from "chalk";
|
|
1459
|
+
function buildDirectoryTree(files) {
|
|
1460
|
+
const root = {
|
|
1461
|
+
name: ".",
|
|
1462
|
+
path: ".",
|
|
1463
|
+
type: "directory",
|
|
1464
|
+
children: []
|
|
1465
|
+
};
|
|
1466
|
+
for (const [filePath, ownership] of Object.entries(files)) {
|
|
1467
|
+
const parts = filePath.split("/").filter((p2) => p2.length > 0);
|
|
1468
|
+
let current = root;
|
|
1469
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1470
|
+
const part = parts[i];
|
|
1471
|
+
const isFile = i === parts.length - 1;
|
|
1472
|
+
const currentPath = parts.slice(0, i + 1).join("/");
|
|
1473
|
+
let node = current.children.find((child) => child.name === part);
|
|
1474
|
+
if (!node) {
|
|
1475
|
+
node = {
|
|
1476
|
+
name: part,
|
|
1477
|
+
path: currentPath,
|
|
1478
|
+
type: isFile ? "file" : "directory",
|
|
1479
|
+
children: []
|
|
1480
|
+
};
|
|
1481
|
+
current.children.push(node);
|
|
1482
|
+
}
|
|
1483
|
+
if (isFile) {
|
|
1484
|
+
node.owner = ownership.owner;
|
|
1485
|
+
node.share = ownership.share;
|
|
1486
|
+
node.lines = ownership.totalLines;
|
|
1487
|
+
node.fileOwnership = ownership;
|
|
1488
|
+
node.lastCommitDate = ownership.lastCommitDate;
|
|
1489
|
+
}
|
|
1490
|
+
current = node;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
function sortNode(node) {
|
|
1494
|
+
node.children.sort((a, b) => {
|
|
1495
|
+
if (a.type !== b.type) {
|
|
1496
|
+
return a.type === "directory" ? -1 : 1;
|
|
1497
|
+
}
|
|
1498
|
+
return a.name.localeCompare(b.name);
|
|
1499
|
+
});
|
|
1500
|
+
for (const child of node.children) {
|
|
1501
|
+
sortNode(child);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
sortNode(root);
|
|
1505
|
+
return root;
|
|
1506
|
+
}
|
|
1507
|
+
function calculateDirectoryStats(node) {
|
|
1508
|
+
if (node.type === "file") {
|
|
1509
|
+
const owner = node.owner || "";
|
|
1510
|
+
return {
|
|
1511
|
+
totalFiles: 1,
|
|
1512
|
+
totalLines: node.lines || 0,
|
|
1513
|
+
ownerCounts: owner ? { [owner]: 1 } : {}
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
let totalFiles = 0;
|
|
1517
|
+
let totalLines = 0;
|
|
1518
|
+
const ownerCounts = {};
|
|
1519
|
+
let latestCommitDate;
|
|
1520
|
+
for (const child of node.children) {
|
|
1521
|
+
const stats = calculateDirectoryStats(child);
|
|
1522
|
+
totalFiles += stats.totalFiles;
|
|
1523
|
+
totalLines += stats.totalLines;
|
|
1524
|
+
for (const [owner, count] of Object.entries(stats.ownerCounts)) {
|
|
1525
|
+
ownerCounts[owner] = (ownerCounts[owner] || 0) + count;
|
|
1526
|
+
}
|
|
1527
|
+
if (child.lastCommitDate) {
|
|
1528
|
+
if (!latestCommitDate || child.lastCommitDate > latestCommitDate) {
|
|
1529
|
+
latestCommitDate = child.lastCommitDate;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
let primaryOwner = "";
|
|
1534
|
+
let maxFiles = 0;
|
|
1535
|
+
for (const [owner, count] of Object.entries(ownerCounts)) {
|
|
1536
|
+
if (count > maxFiles) {
|
|
1537
|
+
maxFiles = count;
|
|
1538
|
+
primaryOwner = owner;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
node.owner = primaryOwner;
|
|
1542
|
+
node.share = totalFiles > 0 ? Math.round(maxFiles / totalFiles * 100) : 0;
|
|
1543
|
+
node.files = maxFiles;
|
|
1544
|
+
node.totalFiles = totalFiles;
|
|
1545
|
+
node.lines = totalLines;
|
|
1546
|
+
node.lastCommitDate = latestCommitDate;
|
|
1547
|
+
return { totalFiles, totalLines, ownerCounts };
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// src/cli/output/utils/ownership-formatter.ts
|
|
1551
|
+
function formatNumber(num) {
|
|
1552
|
+
if (num >= 1e3) {
|
|
1553
|
+
const k = num / 1e3;
|
|
1554
|
+
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
|
|
1555
|
+
}
|
|
1556
|
+
return num.toString();
|
|
1557
|
+
}
|
|
1558
|
+
function getDensityIndicator(share) {
|
|
1559
|
+
if (share > 80) {
|
|
1560
|
+
return "\u2588";
|
|
1561
|
+
}
|
|
1562
|
+
if (share > 50) {
|
|
1563
|
+
return "\u2593";
|
|
1564
|
+
}
|
|
1565
|
+
return "\u2591";
|
|
1566
|
+
}
|
|
1567
|
+
function calculateColumnWidths(terminalWidth) {
|
|
1568
|
+
const minTerminalWidth = 80;
|
|
1569
|
+
const effectiveWidth = Math.max(terminalWidth || minTerminalWidth, minTerminalWidth);
|
|
1570
|
+
const treeConnectorSpace = 12;
|
|
1571
|
+
const pathWidth = Math.min(40, Math.floor((effectiveWidth - treeConnectorSpace) * 0.4));
|
|
1572
|
+
const ownerWidth = 20;
|
|
1573
|
+
const shareWidth = 6;
|
|
1574
|
+
const linesWidth = 8;
|
|
1575
|
+
return {
|
|
1576
|
+
pathWidth,
|
|
1577
|
+
ownerWidth,
|
|
1578
|
+
shareWidth,
|
|
1579
|
+
linesWidth
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function stripAnsi(str) {
|
|
1583
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1584
|
+
}
|
|
1585
|
+
function padRight(str, width) {
|
|
1586
|
+
const visibleLength = stripAnsi(str).length;
|
|
1587
|
+
const padding = Math.max(0, width - visibleLength);
|
|
1588
|
+
return str + " ".repeat(padding);
|
|
1589
|
+
}
|
|
1590
|
+
function padLeft(str, width) {
|
|
1591
|
+
const visibleLength = stripAnsi(str).length;
|
|
1592
|
+
const padding = Math.max(0, width - visibleLength);
|
|
1593
|
+
return " ".repeat(padding) + str;
|
|
1594
|
+
}
|
|
1595
|
+
function isTestOrSnapshotPath(path) {
|
|
1596
|
+
const lowerPath = path.toLowerCase();
|
|
1597
|
+
return lowerPath.includes("__snapshots__") || lowerPath.includes(".snap") || lowerPath.includes(".test.") || lowerPath.includes(".spec.") || lowerPath.includes("__tests__") || lowerPath.includes("__test__") || lowerPath.includes("/test/") || lowerPath.includes("/tests/") || lowerPath.includes("/spec/") || lowerPath.includes("/specs/");
|
|
1598
|
+
}
|
|
1599
|
+
function shouldCollapseTrivialFolder(node) {
|
|
1600
|
+
return (node.lines || 0) < 100;
|
|
1601
|
+
}
|
|
1602
|
+
function flattenSingleChildPaths(node) {
|
|
1603
|
+
const flattened = {
|
|
1604
|
+
...node,
|
|
1605
|
+
children: []
|
|
1606
|
+
};
|
|
1607
|
+
for (const child of node.children) {
|
|
1608
|
+
if (child.type === "directory" && child.children.length === 1) {
|
|
1609
|
+
const singleChild = child.children[0];
|
|
1610
|
+
if (singleChild.type === "directory") {
|
|
1611
|
+
const flattenedChild = flattenSingleChildPaths(singleChild);
|
|
1612
|
+
flattenedChild.name = `${child.name}/${singleChild.name}`;
|
|
1613
|
+
flattenedChild.path = singleChild.path;
|
|
1614
|
+
flattened.children.push(flattenedChild);
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
const processedChild = flattenSingleChildPaths(child);
|
|
1619
|
+
flattened.children.push(processedChild);
|
|
1620
|
+
}
|
|
1621
|
+
return flattened;
|
|
1622
|
+
}
|
|
1623
|
+
function calculateKnowledgeConcentrationAreas(node, concentrationAreas, directory) {
|
|
1624
|
+
if (node.type === "directory" && node.owner && node.share === 100 && (node.lines || 0) >= 500) {
|
|
1625
|
+
if (directory) {
|
|
1626
|
+
const normalizedDir = directory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
1627
|
+
const normalizedPath = node.path.replace(/^\.\//, "");
|
|
1628
|
+
if (normalizedPath !== normalizedDir && !normalizedPath.startsWith(`${normalizedDir}/`) && !normalizedDir.startsWith(`${normalizedPath}/`)) {
|
|
1629
|
+
} else {
|
|
1630
|
+
concentrationAreas.push({
|
|
1631
|
+
path: node.path,
|
|
1632
|
+
owner: node.owner,
|
|
1633
|
+
share: node.share,
|
|
1634
|
+
lines: node.lines || 0
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
} else {
|
|
1638
|
+
concentrationAreas.push({
|
|
1639
|
+
path: node.path,
|
|
1640
|
+
owner: node.owner,
|
|
1641
|
+
share: node.share,
|
|
1642
|
+
lines: node.lines || 0
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
for (const child of node.children) {
|
|
1647
|
+
if (child.type === "directory") {
|
|
1648
|
+
calculateKnowledgeConcentrationAreas(child, concentrationAreas, directory);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
function formatRelativeTime(date) {
|
|
1653
|
+
const now = /* @__PURE__ */ new Date();
|
|
1654
|
+
const diffMs = now.getTime() - date.getTime();
|
|
1655
|
+
const diffDays = Math.floor(diffMs / (1e3 * 60 * 60 * 24));
|
|
1656
|
+
if (diffDays === 0) {
|
|
1657
|
+
return "today";
|
|
1658
|
+
}
|
|
1659
|
+
if (diffDays === 1) {
|
|
1660
|
+
return "1d ago";
|
|
1661
|
+
}
|
|
1662
|
+
if (diffDays < 7) {
|
|
1663
|
+
return `${diffDays}d ago`;
|
|
1664
|
+
}
|
|
1665
|
+
if (diffDays < 30) {
|
|
1666
|
+
const weeks = Math.floor(diffDays / 7);
|
|
1667
|
+
return `${weeks}w ago`;
|
|
1668
|
+
}
|
|
1669
|
+
if (diffDays < 365) {
|
|
1670
|
+
const months = Math.floor(diffDays / 30);
|
|
1671
|
+
return `${months}mo ago`;
|
|
1672
|
+
}
|
|
1673
|
+
const years = Math.floor(diffDays / 365);
|
|
1674
|
+
return `${years}y ago`;
|
|
1675
|
+
}
|
|
1676
|
+
function printKnowledgeConcentration(tree, columnWidths, directory) {
|
|
1677
|
+
const concentrationAreas = [];
|
|
1678
|
+
calculateKnowledgeConcentrationAreas(tree, concentrationAreas, directory);
|
|
1679
|
+
if (concentrationAreas.length === 0) {
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
concentrationAreas.sort((a, b) => b.lines - a.lines);
|
|
1683
|
+
const topConcentrations = concentrationAreas.slice(0, 3);
|
|
1684
|
+
console.log(chalk4.bold.cyan("\u{1F4CA} Top Knowledge Concentration (Density)"));
|
|
1685
|
+
for (let i = 0; i < topConcentrations.length; i++) {
|
|
1686
|
+
const concentration = topConcentrations[i];
|
|
1687
|
+
const displayPath = concentration.path.replace(/^\.\//, "");
|
|
1688
|
+
const maxPathWidth = columnWidths.pathWidth - 5;
|
|
1689
|
+
const truncatedPath = displayPath.length > maxPathWidth ? `${displayPath.slice(0, maxPathWidth - 3)}...` : displayPath;
|
|
1690
|
+
const pathDisplay = padRight(`\u{1F4C2} ${truncatedPath}`, columnWidths.pathWidth);
|
|
1691
|
+
const ownerDisplay = padRight(chalk4.bold(concentration.owner), columnWidths.ownerWidth);
|
|
1692
|
+
const shareDisplay = padLeft(`${concentration.share}%`, columnWidths.shareWidth);
|
|
1693
|
+
const linesDisplay = padLeft(formatNumber(concentration.lines), columnWidths.linesWidth);
|
|
1694
|
+
console.log(
|
|
1695
|
+
` ${i + 1}. ${pathDisplay} ${chalk4.red.bold("\u2588")} ${ownerDisplay} ${chalk4.dim(`[${shareDisplay} / ${linesDisplay} lines]`)}`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
console.log("");
|
|
1699
|
+
}
|
|
1700
|
+
function printHeader(columnWidths) {
|
|
1701
|
+
const pathHeader = padRight("DIRECTORY / FOLDER", columnWidths.pathWidth);
|
|
1702
|
+
const ownerHeader = padRight("OWNER", columnWidths.ownerWidth);
|
|
1703
|
+
const shareHeader = padRight("SHARE", columnWidths.shareWidth);
|
|
1704
|
+
const linesHeader = padRight("LINES", columnWidths.linesWidth);
|
|
1705
|
+
console.log(`${pathHeader} ${ownerHeader} ${shareHeader} ${linesHeader}`);
|
|
1706
|
+
const separator = "\u2500".repeat(
|
|
1707
|
+
columnWidths.pathWidth + columnWidths.ownerWidth + columnWidths.shareWidth + columnWidths.linesWidth + 3
|
|
1708
|
+
);
|
|
1709
|
+
console.log(chalk4.dim(`\u2500\u2500\u2500\u252C${separator}`));
|
|
1710
|
+
}
|
|
1711
|
+
function formatDirectoryName(name, maxWidth) {
|
|
1712
|
+
const displayName = name.startsWith(".") ? name : name;
|
|
1713
|
+
const fullName = `\u{1F4C2} ${displayName}/`;
|
|
1714
|
+
const visibleLength = stripAnsi(fullName).length;
|
|
1715
|
+
if (visibleLength <= maxWidth) {
|
|
1716
|
+
return fullName;
|
|
1717
|
+
}
|
|
1718
|
+
const truncated = `${fullName.slice(0, maxWidth - 3)}...`;
|
|
1719
|
+
return truncated;
|
|
1720
|
+
}
|
|
1721
|
+
function printOwnershipTree(files, options = {}) {
|
|
1722
|
+
const { maxDepth = Infinity, directory } = options;
|
|
1723
|
+
const tree = buildDirectoryTree(files);
|
|
1724
|
+
calculateDirectoryStats(tree);
|
|
1725
|
+
const flattenedTree = flattenSingleChildPaths(tree);
|
|
1726
|
+
calculateDirectoryStats(flattenedTree);
|
|
1727
|
+
const terminalWidth = process.stdout.columns || 80;
|
|
1728
|
+
const columnWidths = calculateColumnWidths(terminalWidth);
|
|
1729
|
+
console.log(chalk4.bold.cyan("\u{1F4C1} Logic Distribution (Knowledge base)"));
|
|
1730
|
+
console.log(
|
|
1731
|
+
chalk4.dim(" Density: ") + chalk4.red.bold("\u2588 Solo (>80%)") + chalk4.dim(" | ") + chalk4.yellow.bold("\u2593 Focused (>50%)") + chalk4.dim(" | ") + chalk4.green.bold("\u2591 Distributed")
|
|
1732
|
+
);
|
|
1733
|
+
console.log("");
|
|
1734
|
+
printKnowledgeConcentration(flattenedTree, columnWidths, directory);
|
|
1735
|
+
printHeader(columnWidths);
|
|
1736
|
+
function printNode(node, prefix, isLast, depth, isRoot = false) {
|
|
1737
|
+
if (depth > maxDepth) {
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (isRoot) {
|
|
1741
|
+
let children = node.children.filter((child) => child.type === "directory");
|
|
1742
|
+
if (directory) {
|
|
1743
|
+
const normalizedDir = directory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
1744
|
+
const dirParts = normalizedDir.split("/");
|
|
1745
|
+
const firstDirPart = dirParts[0];
|
|
1746
|
+
children = children.filter((child) => {
|
|
1747
|
+
const normalizedPath = child.path.replace(/^\.\//, "");
|
|
1748
|
+
return normalizedPath === firstDirPart || normalizedPath.startsWith(`${firstDirPart}/`);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
const processedChildren = [];
|
|
1752
|
+
let trivialGroup = [];
|
|
1753
|
+
for (let i = 0; i < children.length; i++) {
|
|
1754
|
+
const child = children[i];
|
|
1755
|
+
const isTrivial = shouldCollapseTrivialFolder(child);
|
|
1756
|
+
if (isTrivial) {
|
|
1757
|
+
trivialGroup.push(child);
|
|
1758
|
+
} else {
|
|
1759
|
+
if (trivialGroup.length > 0) {
|
|
1760
|
+
const trivialPrefix = "";
|
|
1761
|
+
console.log(
|
|
1762
|
+
trivialPrefix + chalk4.dim(
|
|
1763
|
+
`\u2514\u2500\u2500 [+ ${trivialGroup.length} minor folder${trivialGroup.length !== 1 ? "s" : ""}]`
|
|
1764
|
+
)
|
|
1765
|
+
);
|
|
1766
|
+
trivialGroup = [];
|
|
1767
|
+
}
|
|
1768
|
+
processedChildren.push(child);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (trivialGroup.length > 0) {
|
|
1772
|
+
console.log(
|
|
1773
|
+
chalk4.dim(
|
|
1774
|
+
`\u2514\u2500\u2500 [+ ${trivialGroup.length} minor folder${trivialGroup.length !== 1 ? "s" : ""}]`
|
|
1775
|
+
)
|
|
1776
|
+
);
|
|
1777
|
+
}
|
|
1778
|
+
for (let i = 0; i < processedChildren.length; i++) {
|
|
1779
|
+
const child = processedChildren[i];
|
|
1780
|
+
const isLastChild = i === processedChildren.length - 1;
|
|
1781
|
+
printNode(child, "", isLastChild, depth + 1, false);
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
if (node.type === "directory") {
|
|
1786
|
+
if (directory) {
|
|
1787
|
+
const normalizedDir = directory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
1788
|
+
const normalizedPath = node.path.replace(/^\.\//, "");
|
|
1789
|
+
if (normalizedPath !== normalizedDir && !normalizedPath.startsWith(`${normalizedDir}/`) && !normalizedDir.startsWith(`${normalizedPath}/`)) {
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const isTrivial = shouldCollapseTrivialFolder(node);
|
|
1794
|
+
if (!isTrivial) {
|
|
1795
|
+
if (node.owner && node.share !== void 0 && node.lines !== void 0) {
|
|
1796
|
+
const densityIndicator = getDensityIndicator(node.share);
|
|
1797
|
+
const displayName = formatDirectoryName(
|
|
1798
|
+
node.name,
|
|
1799
|
+
columnWidths.pathWidth - prefix.length - 4
|
|
1800
|
+
);
|
|
1801
|
+
const pathDisplay = padRight(
|
|
1802
|
+
prefix + (isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ") + displayName,
|
|
1803
|
+
columnWidths.pathWidth
|
|
1804
|
+
);
|
|
1805
|
+
let ownerDisplay = padRight(chalk4.bold(node.owner), columnWidths.ownerWidth);
|
|
1806
|
+
let shareDisplay = padLeft(`${node.share}%`, columnWidths.shareWidth);
|
|
1807
|
+
let linesDisplay = padLeft(formatNumber(node.lines), columnWidths.linesWidth);
|
|
1808
|
+
let dateDisplay = "";
|
|
1809
|
+
if (node.lastCommitDate) {
|
|
1810
|
+
const relativeTime = formatRelativeTime(node.lastCommitDate);
|
|
1811
|
+
dateDisplay = ` / ${relativeTime}`;
|
|
1812
|
+
}
|
|
1813
|
+
if (directory && isTestOrSnapshotPath(node.path)) {
|
|
1814
|
+
ownerDisplay = padRight(chalk4.gray(node.owner), columnWidths.ownerWidth);
|
|
1815
|
+
shareDisplay = padLeft(chalk4.gray(`${node.share}%`), columnWidths.shareWidth);
|
|
1816
|
+
linesDisplay = padLeft(chalk4.gray(formatNumber(node.lines)), columnWidths.linesWidth);
|
|
1817
|
+
const grayPathDisplay = padRight(
|
|
1818
|
+
chalk4.gray(prefix + (isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ") + displayName),
|
|
1819
|
+
columnWidths.pathWidth
|
|
1820
|
+
);
|
|
1821
|
+
console.log(
|
|
1822
|
+
`${grayPathDisplay} ${ownerDisplay} ${shareDisplay} ${linesDisplay}${chalk4.gray(dateDisplay)}`
|
|
1823
|
+
);
|
|
1824
|
+
} else {
|
|
1825
|
+
let coloredIndicator = densityIndicator;
|
|
1826
|
+
if (node.share > 80) {
|
|
1827
|
+
coloredIndicator = chalk4.red.bold(densityIndicator);
|
|
1828
|
+
} else if (node.share > 50) {
|
|
1829
|
+
coloredIndicator = chalk4.yellow.bold(densityIndicator);
|
|
1830
|
+
} else {
|
|
1831
|
+
coloredIndicator = chalk4.green.bold(densityIndicator);
|
|
1832
|
+
}
|
|
1833
|
+
console.log(
|
|
1834
|
+
`${pathDisplay} ${coloredIndicator} ${ownerDisplay} ${shareDisplay} ${linesDisplay}${chalk4.dim(dateDisplay)}`
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
if (node.children.length > 0) {
|
|
1839
|
+
const childPrefix = prefix + (isLast ? " " : "\u2502 ");
|
|
1840
|
+
let visibleChildren = node.children.filter((child) => child.type === "directory");
|
|
1841
|
+
if (directory) {
|
|
1842
|
+
const normalizedDir = directory.replace(/^\.\//, "").replace(/\/$/, "");
|
|
1843
|
+
visibleChildren = visibleChildren.filter((child) => {
|
|
1844
|
+
const normalizedPath = child.path.replace(/^\.\//, "");
|
|
1845
|
+
return normalizedPath === normalizedDir || normalizedPath.startsWith(`${normalizedDir}/`) || normalizedDir.startsWith(`${normalizedPath}/`);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
const processedChildren = [];
|
|
1849
|
+
let trivialGroup = [];
|
|
1850
|
+
for (let i = 0; i < visibleChildren.length; i++) {
|
|
1851
|
+
const child = visibleChildren[i];
|
|
1852
|
+
const isTrivialChild = shouldCollapseTrivialFolder(child);
|
|
1853
|
+
if (isTrivialChild) {
|
|
1854
|
+
trivialGroup.push(child);
|
|
1855
|
+
} else {
|
|
1856
|
+
if (trivialGroup.length > 0) {
|
|
1857
|
+
console.log(
|
|
1858
|
+
childPrefix + chalk4.dim(
|
|
1859
|
+
`\u2514\u2500\u2500 [+ ${trivialGroup.length} minor folder${trivialGroup.length !== 1 ? "s" : ""}]`
|
|
1860
|
+
)
|
|
1861
|
+
);
|
|
1862
|
+
trivialGroup = [];
|
|
1863
|
+
}
|
|
1864
|
+
processedChildren.push(child);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (trivialGroup.length > 0) {
|
|
1868
|
+
console.log(
|
|
1869
|
+
childPrefix + chalk4.dim(
|
|
1870
|
+
`\u2514\u2500\u2500 [+ ${trivialGroup.length} minor folder${trivialGroup.length !== 1 ? "s" : ""}]`
|
|
1871
|
+
)
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
for (let i = 0; i < processedChildren.length; i++) {
|
|
1875
|
+
const child = processedChildren[i];
|
|
1876
|
+
const isLastChild = i === processedChildren.length - 1;
|
|
1877
|
+
printNode(child, childPrefix, isLastChild, depth + 1, false);
|
|
1878
|
+
}
|
|
1879
|
+
if (node.children.some((child) => child.type === "file")) {
|
|
1880
|
+
const fileCount = node.children.filter((child) => child.type === "file").length;
|
|
1881
|
+
console.log(
|
|
1882
|
+
childPrefix + chalk4.dim(`\u2514\u2500\u2500 ... ${fileCount} file${fileCount !== 1 ? "s" : ""}`)
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
printNode(flattenedTree, "", true, 0, true);
|
|
1890
|
+
console.log("");
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// src/cli/output/printers/ownership-printer.ts
|
|
1894
|
+
var OwnershipPrinter = class {
|
|
1895
|
+
directory;
|
|
1896
|
+
constructor(directory) {
|
|
1897
|
+
this.directory = directory;
|
|
1898
|
+
}
|
|
1899
|
+
print(result, _timeRangeDescription) {
|
|
1900
|
+
this.printFileOwnershipTree(result.files);
|
|
1901
|
+
}
|
|
1902
|
+
printFileOwnershipTree(files) {
|
|
1903
|
+
const fileEntries = Object.values(files);
|
|
1904
|
+
if (fileEntries.length === 0) {
|
|
1905
|
+
console.log(chalk5.yellow(`
|
|
1906
|
+
\u26A0 No files found in the specified time range.
|
|
1907
|
+
`));
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const filesRecord = {};
|
|
1911
|
+
for (const file of fileEntries) {
|
|
1912
|
+
filesRecord[file.file] = file;
|
|
1913
|
+
}
|
|
1914
|
+
printOwnershipTree(filesRecord, {
|
|
1915
|
+
maxDepth: 10,
|
|
1916
|
+
directory: this.directory
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
// src/cli/output/printers/period-printer.ts
|
|
1922
|
+
import chalk6 from "chalk";
|
|
1923
|
+
var PeriodPrinter = class {
|
|
1924
|
+
constructor(periodUnit) {
|
|
1925
|
+
this.periodUnit = periodUnit;
|
|
1926
|
+
}
|
|
1927
|
+
print(result, timeRangeDescription) {
|
|
1928
|
+
if (result.length === 0) {
|
|
1929
|
+
console.log(chalk6.yellow(`
|
|
1930
|
+
\u26A0 No commits found in the specified time range.
|
|
1931
|
+
`));
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const periodLabel = this.periodUnit === "daily" ? "Date" : this.periodUnit === "weekly" ? "Week" : "Month";
|
|
1935
|
+
const table = createTable({
|
|
1936
|
+
head: [
|
|
1937
|
+
chalk6.bold(periodLabel),
|
|
1938
|
+
chalk6.bold("Author"),
|
|
1939
|
+
chalk6.bold("Commits"),
|
|
1940
|
+
chalk6.bold("Insertions"),
|
|
1941
|
+
chalk6.bold("Deletions"),
|
|
1942
|
+
chalk6.bold("Total Impact")
|
|
1943
|
+
],
|
|
1944
|
+
colAligns: ["left", "left", "right", "right", "right", "right"]
|
|
1945
|
+
});
|
|
1946
|
+
const uniqueAuthors = new Set(result.map((s) => s.author)).size;
|
|
1947
|
+
const uniquePeriods = new Set(result.map((s) => s.period)).size;
|
|
1948
|
+
const totalCommits = result.reduce((sum, s) => sum + s.stats.commits, 0);
|
|
1949
|
+
const totalInsertions = result.reduce((sum, s) => sum + s.stats.insertions, 0);
|
|
1950
|
+
const totalDeletions = result.reduce((sum, s) => sum + s.stats.deletions, 0);
|
|
1951
|
+
for (const item of result) {
|
|
1952
|
+
const totalImpact = item.stats.insertions + item.stats.deletions;
|
|
1953
|
+
table.push([
|
|
1954
|
+
chalk6.cyan(item.period),
|
|
1955
|
+
item.author,
|
|
1956
|
+
chalk6.white(item.stats.commits.toString()),
|
|
1957
|
+
chalk6.green(`+${item.stats.insertions.toLocaleString()}`),
|
|
1958
|
+
chalk6.red(`-${item.stats.deletions.toLocaleString()}`),
|
|
1959
|
+
chalk6.bold(totalImpact.toLocaleString())
|
|
1960
|
+
]);
|
|
1961
|
+
}
|
|
1962
|
+
table.push(
|
|
1963
|
+
createSummaryRow({
|
|
1964
|
+
totalCommits,
|
|
1965
|
+
totalInsertions,
|
|
1966
|
+
totalDeletions,
|
|
1967
|
+
timeRangeDescription,
|
|
1968
|
+
additionalInfo: `${uniqueAuthors} authors / ${uniquePeriods} ${periodLabel.toLowerCase()}s`
|
|
1969
|
+
})
|
|
1970
|
+
);
|
|
1971
|
+
console.log(`
|
|
1972
|
+
${table.toString()}
|
|
1973
|
+
`);
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
// src/cli/output/printers/printer-factory.ts
|
|
1978
|
+
function createPrinter(mode, periodUnit = "daily", directory) {
|
|
1979
|
+
switch (mode) {
|
|
1980
|
+
case "aggregate":
|
|
1981
|
+
return new AggregatePrinter();
|
|
1982
|
+
case "periodic":
|
|
1983
|
+
return new PeriodPrinter(periodUnit);
|
|
1984
|
+
case "ownership":
|
|
1985
|
+
return new OwnershipPrinter(directory);
|
|
1986
|
+
default:
|
|
1987
|
+
throw new Error(`Unknown analysis mode: ${mode}`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// src/cli/validators/cli-options-validator.ts
|
|
1992
|
+
function validateCLIOptions(options) {
|
|
1993
|
+
if (!VALID_MODES.includes(options.mode)) {
|
|
1994
|
+
return {
|
|
1995
|
+
success: false,
|
|
1996
|
+
error: `Invalid --mode value: ${options.mode}. Must be one of: ${VALID_MODES.join(", ")}`
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
if (options.periodUnit && !VALID_PERIOD_UNITS.includes(options.periodUnit)) {
|
|
2000
|
+
return {
|
|
2001
|
+
success: false,
|
|
2002
|
+
error: `Invalid --period-unit value: ${options.periodUnit}. Must be one of: ${VALID_PERIOD_UNITS.join(", ")}`
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
if (options.since || options.until) {
|
|
2006
|
+
try {
|
|
2007
|
+
validateTimeRange(options.since, options.until);
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
return {
|
|
2010
|
+
success: false,
|
|
2011
|
+
error: error instanceof Error ? error.message : "Invalid time range"
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (options.days !== void 0 && options.days <= 0) {
|
|
2016
|
+
return {
|
|
2017
|
+
success: false,
|
|
2018
|
+
error: "--days must be a positive number"
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
return { success: true };
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// src/cli/index.ts
|
|
2025
|
+
async function main() {
|
|
2026
|
+
const program = createCommand();
|
|
2027
|
+
const rawOptions = parseArgs(program);
|
|
2028
|
+
const options = {
|
|
2029
|
+
...rawOptions,
|
|
2030
|
+
mode: rawOptions.mode || "aggregate"
|
|
2031
|
+
};
|
|
2032
|
+
const validationResult = validateCLIOptions(options);
|
|
2033
|
+
if (!validationResult.success) {
|
|
2034
|
+
console.error(chalk7.red(validationResult.error));
|
|
2035
|
+
process.exit(1);
|
|
2036
|
+
}
|
|
2037
|
+
const analyzerOptions = adaptToAnalyzerOptions(options);
|
|
2038
|
+
const timeRangeDescription = buildTimeRangeDescription(options);
|
|
2039
|
+
p.intro(chalk7.bgCyan(chalk7.black(" gimpact ")));
|
|
2040
|
+
const s = p.spinner();
|
|
2041
|
+
s.start("Analyzing Git repository...");
|
|
2042
|
+
try {
|
|
2043
|
+
const stats = await analyzeContributions(analyzerOptions);
|
|
2044
|
+
s.stop("Analysis complete!");
|
|
2045
|
+
const printer = createPrinter(
|
|
2046
|
+
analyzerOptions.mode || "aggregate",
|
|
2047
|
+
analyzerOptions.periodUnit,
|
|
2048
|
+
analyzerOptions.directory
|
|
2049
|
+
);
|
|
2050
|
+
printer.print(stats, timeRangeDescription);
|
|
2051
|
+
p.outro(chalk7.green("\u2713 Done!"));
|
|
2052
|
+
} catch (error) {
|
|
2053
|
+
s.stop("Analysis failed");
|
|
2054
|
+
p.cancel(chalk7.red(error instanceof Error ? error.message : "Unknown error occurred"));
|
|
2055
|
+
process.exit(1);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
main().catch((error) => {
|
|
2059
|
+
console.error(chalk7.red("Fatal error:"), error);
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
});
|