commit-report 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1572 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+ import chalk3 from "chalk";
6
+ import { checkbox } from "@inquirer/prompts";
7
+
8
+ // src/scanner/index.ts
9
+ import { readdir, access } from "fs/promises";
10
+ import { join, basename } from "path";
11
+ import { execSync } from "child_process";
12
+ import ora from "ora";
13
+ import { confirm } from "@inquirer/prompts";
14
+ var IGNORE_DIRS = /* @__PURE__ */ new Set([
15
+ "node_modules",
16
+ ".git",
17
+ "vendor",
18
+ "dist",
19
+ "build",
20
+ ".cache",
21
+ ".next",
22
+ ".nuxt",
23
+ "__pycache__",
24
+ "target"
25
+ ]);
26
+ async function scanRepositories(options) {
27
+ const spinner = ora("\u626B\u63CF\u4ED3\u5E93\u4E2D...").start();
28
+ const repos = [];
29
+ let deepScanConfirmed = false;
30
+ async function scan(dir, depth) {
31
+ if (depth > options.maxDepth && !deepScanConfirmed) {
32
+ spinner.stop();
33
+ const shouldContinue = await confirm({
34
+ message: `\u626B\u63CF\u6DF1\u5EA6\u5DF2\u8D85\u8FC7 ${options.maxDepth} \u5C42\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F`,
35
+ default: false
36
+ });
37
+ if (!shouldContinue) {
38
+ return;
39
+ }
40
+ deepScanConfirmed = true;
41
+ spinner.start("\u7EE7\u7EED\u626B\u63CF\u4E2D...");
42
+ }
43
+ try {
44
+ const gitDir = join(dir, ".git");
45
+ await access(gitDir);
46
+ const repoInfo = getRepoInfo(dir);
47
+ if (repoInfo) {
48
+ repos.push(repoInfo);
49
+ spinner.text = `\u626B\u63CF\u4ED3\u5E93\u4E2D... \u5DF2\u627E\u5230 ${repos.length} \u4E2A`;
50
+ }
51
+ return;
52
+ } catch {
53
+ }
54
+ try {
55
+ const entries = await readdir(dir, { withFileTypes: true });
56
+ for (const entry of entries) {
57
+ if (!entry.isDirectory()) continue;
58
+ if (IGNORE_DIRS.has(entry.name)) continue;
59
+ if (entry.name.startsWith(".") && entry.name !== ".git") continue;
60
+ await scan(join(dir, entry.name), depth + 1);
61
+ }
62
+ } catch {
63
+ }
64
+ }
65
+ await scan(options.targetDir, 0);
66
+ if (repos.length > 0) {
67
+ spinner.succeed(`\u627E\u5230 ${repos.length} \u4E2A Git \u4ED3\u5E93`);
68
+ } else {
69
+ spinner.fail("\u672A\u627E\u5230 Git \u4ED3\u5E93");
70
+ }
71
+ return repos;
72
+ }
73
+ function getRepoInfo(repoPath) {
74
+ try {
75
+ const countStr = execSync("git rev-list --count HEAD", {
76
+ cwd: repoPath,
77
+ stdio: ["pipe", "pipe", "ignore"],
78
+ encoding: "utf-8"
79
+ }).trim();
80
+ const commitCount = parseInt(countStr, 10) || 0;
81
+ const name = basename(repoPath);
82
+ return {
83
+ path: repoPath,
84
+ name,
85
+ commitCount
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // src/analyzer/index.ts
93
+ import ora2 from "ora";
94
+ import chalk from "chalk";
95
+
96
+ // src/analyzer/git-log-parser.ts
97
+ import { execSync as execSync2 } from "child_process";
98
+ import { readFile } from "fs/promises";
99
+ import { join as join2 } from "path";
100
+ import ig from "ignore";
101
+ var COMMIT_SEPARATOR = "---COMMITX_SEP---";
102
+ var FIELD_SEPARATOR = "|";
103
+ var FORMAT = `${COMMIT_SEPARATOR}%H${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%s`;
104
+ async function parseGitLog(repoPath, timeRange, author) {
105
+ const ignoreFilter = await loadGitignore(repoPath);
106
+ const args = [
107
+ "git",
108
+ "log",
109
+ `--format="${FORMAT}"`,
110
+ "--numstat"
111
+ ];
112
+ if (timeRange) {
113
+ args.push(`--since="${timeRange.from.toISOString()}"`);
114
+ args.push(`--until="${timeRange.to.toISOString()}"`);
115
+ }
116
+ if (author) {
117
+ args.push(`--author="${author}"`);
118
+ }
119
+ let output;
120
+ try {
121
+ output = execSync2(args.join(" "), {
122
+ cwd: repoPath,
123
+ encoding: "utf-8",
124
+ maxBuffer: 100 * 1024 * 1024,
125
+ // 100MB buffer for large repos
126
+ stdio: ["pipe", "pipe", "ignore"]
127
+ });
128
+ } catch {
129
+ return [];
130
+ }
131
+ if (!output.trim()) {
132
+ return [];
133
+ }
134
+ return parseOutput(output, ignoreFilter);
135
+ }
136
+ function parseOutput(output, ignoreFilter) {
137
+ const commits = [];
138
+ const blocks = output.split(COMMIT_SEPARATOR).filter((b) => b.trim());
139
+ for (const block of blocks) {
140
+ const lines = block.trim().split("\n");
141
+ if (lines.length === 0) continue;
142
+ const headerLine = lines[0].replace(/^"|"$/g, "");
143
+ const parts = headerLine.split(FIELD_SEPARATOR);
144
+ if (parts.length < 5) continue;
145
+ const [hash, authorName, email, dateStr, ...messageParts] = parts;
146
+ const message = messageParts.join(FIELD_SEPARATOR);
147
+ const files = [];
148
+ for (let i = 1; i < lines.length; i++) {
149
+ const line = lines[i].trim();
150
+ if (!line) continue;
151
+ const tabParts = line.split(" ");
152
+ if (tabParts.length !== 3) continue;
153
+ const [addedStr, deletedStr, filePath] = tabParts;
154
+ const added = addedStr === "-" ? 0 : parseInt(addedStr, 10) || 0;
155
+ const deleted = deletedStr === "-" ? 0 : parseInt(deletedStr, 10) || 0;
156
+ if (ignoreFilter.ignores(filePath)) continue;
157
+ files.push({ added, deleted, path: filePath });
158
+ }
159
+ commits.push({
160
+ hash,
161
+ author: authorName,
162
+ email,
163
+ date: new Date(dateStr),
164
+ message,
165
+ files
166
+ });
167
+ }
168
+ return commits;
169
+ }
170
+ async function loadGitignore(repoPath) {
171
+ const ignoreInstance = ig();
172
+ try {
173
+ const content = await readFile(join2(repoPath, ".gitignore"), "utf-8");
174
+ ignoreInstance.add(content);
175
+ } catch {
176
+ }
177
+ ignoreInstance.add([
178
+ "package-lock.json",
179
+ "pnpm-lock.yaml",
180
+ "yarn.lock"
181
+ ]);
182
+ return ignoreInstance;
183
+ }
184
+
185
+ // src/analyzer/stats-calculator.ts
186
+ import { extname } from "path";
187
+ function calculateStats(commits) {
188
+ if (commits.length === 0) {
189
+ return emptyStats();
190
+ }
191
+ const sorted = [...commits].sort(
192
+ (a, b) => a.date.getTime() - b.date.getTime()
193
+ );
194
+ let totalLinesAdded = 0;
195
+ let totalLinesDeleted = 0;
196
+ const allFilePaths = /* @__PURE__ */ new Set();
197
+ const authorMap = /* @__PURE__ */ new Map();
198
+ const fileTypeMap = /* @__PURE__ */ new Map();
199
+ const directoryMap = /* @__PURE__ */ new Map();
200
+ const directoryCommitSet = /* @__PURE__ */ new Map();
201
+ const hourlyDistribution = new Array(24).fill(0);
202
+ const dailyHeatmap = {};
203
+ const dailyCounts = /* @__PURE__ */ new Map();
204
+ for (const commit of sorted) {
205
+ const hour = commit.date.getHours();
206
+ hourlyDistribution[hour]++;
207
+ const dateKey = formatDateKey(commit.date);
208
+ dailyHeatmap[dateKey] = (dailyHeatmap[dateKey] || 0) + 1;
209
+ dailyCounts.set(dateKey, (dailyCounts.get(dateKey) || 0) + 1);
210
+ const authorKey = commit.email.toLowerCase();
211
+ let authorStat = authorMap.get(authorKey);
212
+ if (!authorStat) {
213
+ authorStat = {
214
+ name: commit.author,
215
+ email: commit.email,
216
+ commits: 0,
217
+ linesAdded: 0,
218
+ linesDeleted: 0,
219
+ lastActiveDate: commit.date
220
+ };
221
+ authorMap.set(authorKey, authorStat);
222
+ }
223
+ authorStat.commits++;
224
+ authorStat.lastActiveDate = commit.date;
225
+ for (const file of commit.files) {
226
+ totalLinesAdded += file.added;
227
+ totalLinesDeleted += file.deleted;
228
+ allFilePaths.add(file.path);
229
+ authorStat.linesAdded += file.added;
230
+ authorStat.linesDeleted += file.deleted;
231
+ const ext = extname(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
232
+ let ftStat = fileTypeMap.get(ext);
233
+ if (!ftStat) {
234
+ ftStat = { extension: ext, added: 0, deleted: 0, fileCount: 0 };
235
+ fileTypeMap.set(ext, ftStat);
236
+ }
237
+ ftStat.added += file.added;
238
+ ftStat.deleted += file.deleted;
239
+ const topDir = getTopDirectory(file.path);
240
+ let dirStat = directoryMap.get(topDir);
241
+ if (!dirStat) {
242
+ dirStat = { path: topDir, commits: 0, linesChanged: 0 };
243
+ directoryMap.set(topDir, dirStat);
244
+ directoryCommitSet.set(topDir, /* @__PURE__ */ new Set());
245
+ }
246
+ dirStat.linesChanged += file.added + file.deleted;
247
+ directoryCommitSet.get(topDir).add(commit.hash);
248
+ }
249
+ }
250
+ for (const [dir, commitSet] of directoryCommitSet) {
251
+ const dirStat = directoryMap.get(dir);
252
+ if (dirStat) {
253
+ dirStat.commits = commitSet.size;
254
+ }
255
+ }
256
+ const fileCountByExt = /* @__PURE__ */ new Map();
257
+ for (const filePath of allFilePaths) {
258
+ const ext = extname(filePath).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
259
+ if (!fileCountByExt.has(ext)) {
260
+ fileCountByExt.set(ext, /* @__PURE__ */ new Set());
261
+ }
262
+ fileCountByExt.get(ext).add(filePath);
263
+ }
264
+ for (const [ext, files] of fileCountByExt) {
265
+ const ftStat = fileTypeMap.get(ext);
266
+ if (ftStat) {
267
+ ftStat.fileCount = files.size;
268
+ }
269
+ }
270
+ let busiestDay = { date: "", count: 0 };
271
+ for (const [date, count] of dailyCounts) {
272
+ if (count > busiestDay.count) {
273
+ busiestDay = { date, count };
274
+ }
275
+ }
276
+ const authors = Array.from(authorMap.values()).sort(
277
+ (a, b) => b.commits - a.commits
278
+ );
279
+ const fileTypes = Array.from(fileTypeMap.values()).sort(
280
+ (a, b) => b.added + b.deleted - (a.added + a.deleted)
281
+ );
282
+ const directories = Array.from(directoryMap.values()).sort((a, b) => b.linesChanged - a.linesChanged).slice(0, 10);
283
+ return {
284
+ totalCommits: sorted.length,
285
+ linesAdded: totalLinesAdded,
286
+ linesDeleted: totalLinesDeleted,
287
+ filesChanged: allFilePaths.size,
288
+ firstCommitDate: sorted[0].date,
289
+ lastCommitDate: sorted[sorted.length - 1].date,
290
+ busiestDay,
291
+ authors,
292
+ fileTypes,
293
+ directories,
294
+ hourlyDistribution,
295
+ dailyHeatmap,
296
+ quality: calculateQualityMetrics(sorted),
297
+ timePatterns: calculateTimePatterns(sorted),
298
+ trends: calculateTrends(sorted),
299
+ collaboration: calculateCollaboration(sorted),
300
+ messageStats: calculateMessageStats(sorted),
301
+ authorFileTypeContributions: calculateAuthorFileTypeContributions(sorted)
302
+ };
303
+ }
304
+ function mergeStats(statsList) {
305
+ if (statsList.length === 0) return emptyStats();
306
+ if (statsList.length === 1) return statsList[0];
307
+ const merged = emptyStats();
308
+ for (const stats of statsList) {
309
+ merged.totalCommits += stats.totalCommits;
310
+ merged.linesAdded += stats.linesAdded;
311
+ merged.linesDeleted += stats.linesDeleted;
312
+ merged.filesChanged += stats.filesChanged;
313
+ if (!merged.firstCommitDate || stats.firstCommitDate < merged.firstCommitDate) {
314
+ merged.firstCommitDate = stats.firstCommitDate;
315
+ }
316
+ if (!merged.lastCommitDate || stats.lastCommitDate > merged.lastCommitDate) {
317
+ merged.lastCommitDate = stats.lastCommitDate;
318
+ }
319
+ for (let i = 0; i < 24; i++) {
320
+ merged.hourlyDistribution[i] += stats.hourlyDistribution[i];
321
+ }
322
+ for (const [date, count] of Object.entries(stats.dailyHeatmap)) {
323
+ merged.dailyHeatmap[date] = (merged.dailyHeatmap[date] || 0) + count;
324
+ }
325
+ for (const author of stats.authors) {
326
+ const existing = merged.authors.find(
327
+ (a) => a.email.toLowerCase() === author.email.toLowerCase()
328
+ );
329
+ if (existing) {
330
+ existing.commits += author.commits;
331
+ existing.linesAdded += author.linesAdded;
332
+ existing.linesDeleted += author.linesDeleted;
333
+ if (author.lastActiveDate > existing.lastActiveDate) {
334
+ existing.lastActiveDate = author.lastActiveDate;
335
+ }
336
+ } else {
337
+ merged.authors.push({ ...author });
338
+ }
339
+ }
340
+ for (const ft of stats.fileTypes) {
341
+ const existing = merged.fileTypes.find(
342
+ (f) => f.extension === ft.extension
343
+ );
344
+ if (existing) {
345
+ existing.added += ft.added;
346
+ existing.deleted += ft.deleted;
347
+ existing.fileCount += ft.fileCount;
348
+ } else {
349
+ merged.fileTypes.push({ ...ft });
350
+ }
351
+ }
352
+ for (const dir of stats.directories) {
353
+ const existing = merged.directories.find((d) => d.path === dir.path);
354
+ if (existing) {
355
+ existing.commits += dir.commits;
356
+ existing.linesChanged += dir.linesChanged;
357
+ } else {
358
+ merged.directories.push({ ...dir });
359
+ }
360
+ }
361
+ merged.quality.avgFilesPerCommit += stats.quality.avgFilesPerCommit;
362
+ merged.quality.avgLinesPerCommit += stats.quality.avgLinesPerCommit;
363
+ merged.quality.churnRate += stats.quality.churnRate;
364
+ for (const hf of stats.quality.hotFiles) {
365
+ const existing = merged.quality.hotFiles.find((h) => h.path === hf.path);
366
+ if (existing) {
367
+ existing.modifyCount += hf.modifyCount;
368
+ for (const author of hf.authors) {
369
+ if (!existing.authors.includes(author)) {
370
+ existing.authors.push(author);
371
+ }
372
+ }
373
+ } else {
374
+ merged.quality.hotFiles.push({ ...hf, authors: [...hf.authors] });
375
+ }
376
+ }
377
+ for (let i = 0; i < 7; i++) {
378
+ merged.timePatterns.weekdayDistribution[i] += stats.timePatterns.weekdayDistribution[i];
379
+ }
380
+ for (const wp of stats.trends.weeklyTrend) {
381
+ const existing = merged.trends.weeklyTrend.find((w) => w.week === wp.week);
382
+ if (existing) {
383
+ existing.commits += wp.commits;
384
+ existing.linesAdded += wp.linesAdded;
385
+ existing.linesDeleted += wp.linesDeleted;
386
+ } else {
387
+ merged.trends.weeklyTrend.push({ ...wp });
388
+ }
389
+ }
390
+ for (const cp of stats.trends.cumulativeLines) {
391
+ const existing = merged.trends.cumulativeLines.find(
392
+ (c) => c.date === cp.date
393
+ );
394
+ if (existing) {
395
+ existing.netLines += cp.netLines;
396
+ } else {
397
+ merged.trends.cumulativeLines.push({ ...cp });
398
+ }
399
+ }
400
+ for (const sf of stats.collaboration.soloFiles) {
401
+ const existing = merged.collaboration.soloFiles.find(
402
+ (s) => s.path === sf.path
403
+ );
404
+ if (existing) {
405
+ existing.commits += sf.commits;
406
+ } else {
407
+ merged.collaboration.soloFiles.push({ ...sf });
408
+ }
409
+ }
410
+ for (const ch of stats.collaboration.collaborationHotspots) {
411
+ const existing = merged.collaboration.collaborationHotspots.find(
412
+ (c) => c.path === ch.path
413
+ );
414
+ if (existing) {
415
+ existing.totalCommits += ch.totalCommits;
416
+ existing.authorCount = Math.max(existing.authorCount, ch.authorCount);
417
+ } else {
418
+ merged.collaboration.collaborationHotspots.push({ ...ch });
419
+ }
420
+ }
421
+ for (const [type, count] of Object.entries(stats.messageStats.typeDistribution)) {
422
+ merged.messageStats.typeDistribution[type] = (merged.messageStats.typeDistribution[type] || 0) + count;
423
+ }
424
+ merged.messageStats.avgMessageLength += stats.messageStats.avgMessageLength;
425
+ }
426
+ let busiestDay = { date: "", count: 0 };
427
+ for (const [date, count] of Object.entries(merged.dailyHeatmap)) {
428
+ if (count > busiestDay.count) {
429
+ busiestDay = { date, count };
430
+ }
431
+ }
432
+ merged.busiestDay = busiestDay;
433
+ merged.authors.sort((a, b) => b.commits - a.commits);
434
+ merged.fileTypes.sort(
435
+ (a, b) => b.added + b.deleted - (a.added + a.deleted)
436
+ );
437
+ merged.directories.sort((a, b) => b.linesChanged - a.linesChanged);
438
+ merged.directories = merged.directories.slice(0, 10);
439
+ const repoCount = statsList.length;
440
+ merged.quality.avgFilesPerCommit /= repoCount;
441
+ merged.quality.avgLinesPerCommit /= repoCount;
442
+ merged.quality.churnRate /= repoCount;
443
+ merged.quality.hotFiles.sort((a, b) => b.modifyCount - a.modifyCount);
444
+ merged.quality.hotFiles = merged.quality.hotFiles.slice(0, 10);
445
+ const totalWeekdayCommits = merged.timePatterns.weekdayDistribution.reduce(
446
+ (a, b) => a + b,
447
+ 0
448
+ );
449
+ if (totalWeekdayCommits > 0) {
450
+ merged.timePatterns.weekendCommits = (merged.timePatterns.weekdayDistribution[5] + merged.timePatterns.weekdayDistribution[6]) / totalWeekdayCommits;
451
+ }
452
+ merged.trends.weeklyTrend.sort((a, b) => a.week.localeCompare(b.week));
453
+ merged.trends.cumulativeLines.sort((a, b) => a.date.localeCompare(b.date));
454
+ let cumulative = 0;
455
+ for (const point of merged.trends.cumulativeLines) {
456
+ cumulative += point.netLines;
457
+ point.netLines = cumulative;
458
+ }
459
+ merged.collaboration.soloFiles.sort((a, b) => b.commits - a.commits);
460
+ merged.collaboration.soloFiles = merged.collaboration.soloFiles.slice(0, 10);
461
+ merged.collaboration.collaborationHotspots.sort(
462
+ (a, b) => b.totalCommits - a.totalCommits
463
+ );
464
+ merged.collaboration.collaborationHotspots = merged.collaboration.collaborationHotspots.slice(0, 10);
465
+ merged.messageStats.avgMessageLength /= repoCount;
466
+ const contributionMap = /* @__PURE__ */ new Map();
467
+ for (const stats of statsList) {
468
+ for (const contrib of stats.authorFileTypeContributions) {
469
+ const key = `${contrib.email.toLowerCase()}|||${contrib.extension}`;
470
+ const existing = contributionMap.get(key);
471
+ if (existing) {
472
+ existing.linesAdded += contrib.linesAdded;
473
+ existing.linesDeleted += contrib.linesDeleted;
474
+ existing.commits += contrib.commits;
475
+ existing.fileCount += contrib.fileCount;
476
+ } else {
477
+ contributionMap.set(key, { ...contrib });
478
+ }
479
+ }
480
+ }
481
+ merged.authorFileTypeContributions = Array.from(contributionMap.values()).sort((a, b) => {
482
+ const totalA = a.linesAdded + a.linesDeleted;
483
+ const totalB = b.linesAdded + b.linesDeleted;
484
+ return totalB - totalA;
485
+ }).slice(0, 20);
486
+ return merged;
487
+ }
488
+ function emptyStats() {
489
+ return {
490
+ totalCommits: 0,
491
+ linesAdded: 0,
492
+ linesDeleted: 0,
493
+ filesChanged: 0,
494
+ firstCommitDate: /* @__PURE__ */ new Date(),
495
+ lastCommitDate: /* @__PURE__ */ new Date(),
496
+ busiestDay: { date: "", count: 0 },
497
+ authors: [],
498
+ fileTypes: [],
499
+ directories: [],
500
+ hourlyDistribution: new Array(24).fill(0),
501
+ dailyHeatmap: {},
502
+ quality: emptyQualityMetrics(),
503
+ timePatterns: emptyTimePatterns(),
504
+ trends: emptyTrendData(),
505
+ collaboration: emptyCollaborationMetrics(),
506
+ messageStats: emptyMessageStats(),
507
+ authorFileTypeContributions: []
508
+ };
509
+ }
510
+ function getTopDirectory(filePath) {
511
+ const parts = filePath.split("/");
512
+ return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
513
+ }
514
+ function formatDateKey(date) {
515
+ const y = date.getFullYear();
516
+ const m = String(date.getMonth() + 1).padStart(2, "0");
517
+ const d = String(date.getDate()).padStart(2, "0");
518
+ return `${y}-${m}-${d}`;
519
+ }
520
+ function calculateQualityMetrics(commits) {
521
+ if (commits.length === 0) {
522
+ return emptyQualityMetrics();
523
+ }
524
+ const totalFiles = commits.reduce((sum, c) => sum + c.files.length, 0);
525
+ const avgFilesPerCommit = totalFiles / commits.length;
526
+ const totalLines = commits.reduce(
527
+ (sum, c) => sum + c.files.reduce((s, f) => s + f.added + f.deleted, 0),
528
+ 0
529
+ );
530
+ const avgLinesPerCommit = totalLines / commits.length;
531
+ const totalAdded = commits.reduce(
532
+ (sum, c) => sum + c.files.reduce((s, f) => s + f.added, 0),
533
+ 0
534
+ );
535
+ const totalDeleted = commits.reduce(
536
+ (sum, c) => sum + c.files.reduce((s, f) => s + f.deleted, 0),
537
+ 0
538
+ );
539
+ const churnRate = totalAdded > 0 ? totalDeleted / totalAdded : 0;
540
+ const fileModifyMap = /* @__PURE__ */ new Map();
541
+ for (const commit of commits) {
542
+ for (const file of commit.files) {
543
+ const entry = fileModifyMap.get(file.path) || { count: 0, authors: /* @__PURE__ */ new Set() };
544
+ entry.count++;
545
+ entry.authors.add(commit.author);
546
+ fileModifyMap.set(file.path, entry);
547
+ }
548
+ }
549
+ const hotFiles = Array.from(fileModifyMap.entries()).map(([path, data]) => ({
550
+ path,
551
+ modifyCount: data.count,
552
+ authors: Array.from(data.authors)
553
+ })).sort((a, b) => b.modifyCount - a.modifyCount).slice(0, 10);
554
+ return { avgFilesPerCommit, avgLinesPerCommit, churnRate, hotFiles };
555
+ }
556
+ function calculateTimePatterns(commits) {
557
+ if (commits.length === 0) {
558
+ return emptyTimePatterns();
559
+ }
560
+ const weekdayDistribution = new Array(7).fill(0);
561
+ for (const commit of commits) {
562
+ const day = commit.date.getDay();
563
+ const idx = day === 0 ? 6 : day - 1;
564
+ weekdayDistribution[idx]++;
565
+ }
566
+ const weekendCommits = (weekdayDistribution[5] + weekdayDistribution[6]) / commits.length;
567
+ const sorted = [...commits].sort(
568
+ (a, b) => a.date.getTime() - b.date.getTime()
569
+ );
570
+ let totalInterval = 0;
571
+ for (let i = 1; i < sorted.length; i++) {
572
+ totalInterval += sorted[i].date.getTime() - sorted[i - 1].date.getTime();
573
+ }
574
+ const avgCommitInterval = sorted.length > 1 ? totalInterval / (sorted.length - 1) / 36e5 : 0;
575
+ const { longestStreak, currentStreak } = calculateStreaks(sorted);
576
+ return {
577
+ weekdayDistribution,
578
+ weekendCommits,
579
+ avgCommitInterval,
580
+ longestStreak,
581
+ currentStreak
582
+ };
583
+ }
584
+ function calculateStreaks(sortedCommits) {
585
+ if (sortedCommits.length === 0) {
586
+ return { longestStreak: 0, currentStreak: 0 };
587
+ }
588
+ const uniqueDates = /* @__PURE__ */ new Set();
589
+ for (const commit of sortedCommits) {
590
+ uniqueDates.add(formatDateKey(commit.date));
591
+ }
592
+ const sortedDates = Array.from(uniqueDates).sort();
593
+ if (sortedDates.length === 0) {
594
+ return { longestStreak: 0, currentStreak: 0 };
595
+ }
596
+ let longestStreak = 1;
597
+ let currentStreakCount = 1;
598
+ let tempStreak = 1;
599
+ for (let i = 1; i < sortedDates.length; i++) {
600
+ const prevDate = new Date(sortedDates[i - 1]);
601
+ const currDate = new Date(sortedDates[i]);
602
+ const diffDays = Math.round(
603
+ (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
604
+ );
605
+ if (diffDays === 1) {
606
+ tempStreak++;
607
+ } else {
608
+ tempStreak = 1;
609
+ }
610
+ longestStreak = Math.max(longestStreak, tempStreak);
611
+ }
612
+ const today = formatDateKey(/* @__PURE__ */ new Date());
613
+ const lastCommitDate = sortedDates[sortedDates.length - 1];
614
+ const lastDate = new Date(lastCommitDate);
615
+ const todayDate = new Date(today);
616
+ const daysSinceLastCommit = Math.round(
617
+ (todayDate.getTime() - lastDate.getTime()) / (1e3 * 60 * 60 * 24)
618
+ );
619
+ if (daysSinceLastCommit <= 1) {
620
+ currentStreakCount = 1;
621
+ for (let i = sortedDates.length - 2; i >= 0; i--) {
622
+ const currDate = new Date(sortedDates[i + 1]);
623
+ const prevDate = new Date(sortedDates[i]);
624
+ const diffDays = Math.round(
625
+ (currDate.getTime() - prevDate.getTime()) / (1e3 * 60 * 60 * 24)
626
+ );
627
+ if (diffDays === 1) {
628
+ currentStreakCount++;
629
+ } else {
630
+ break;
631
+ }
632
+ }
633
+ } else {
634
+ currentStreakCount = 0;
635
+ }
636
+ return { longestStreak, currentStreak: currentStreakCount };
637
+ }
638
+ function getWeekKey(date) {
639
+ const d = new Date(date);
640
+ d.setHours(0, 0, 0, 0);
641
+ d.setDate(d.getDate() + 4 - (d.getDay() || 7));
642
+ const yearStart = new Date(d.getFullYear(), 0, 1);
643
+ const weekNo = Math.ceil(
644
+ ((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7
645
+ );
646
+ return `${d.getFullYear()}-W${String(weekNo).padStart(2, "0")}`;
647
+ }
648
+ function calculateTrends(commits) {
649
+ if (commits.length === 0) {
650
+ return emptyTrendData();
651
+ }
652
+ const weekMap = /* @__PURE__ */ new Map();
653
+ for (const commit of commits) {
654
+ const week = getWeekKey(commit.date);
655
+ const entry = weekMap.get(week) || {
656
+ week,
657
+ commits: 0,
658
+ linesAdded: 0,
659
+ linesDeleted: 0
660
+ };
661
+ entry.commits++;
662
+ for (const file of commit.files) {
663
+ entry.linesAdded += file.added;
664
+ entry.linesDeleted += file.deleted;
665
+ }
666
+ weekMap.set(week, entry);
667
+ }
668
+ const weeklyTrend = Array.from(weekMap.values()).sort(
669
+ (a, b) => a.week.localeCompare(b.week)
670
+ );
671
+ const dailyNet = /* @__PURE__ */ new Map();
672
+ for (const commit of commits) {
673
+ const dateKey = formatDateKey(commit.date);
674
+ const net = commit.files.reduce((sum, f) => sum + f.added - f.deleted, 0);
675
+ dailyNet.set(dateKey, (dailyNet.get(dateKey) || 0) + net);
676
+ }
677
+ let cumulative = 0;
678
+ const cumulativeLines = Array.from(dailyNet.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([date, net]) => {
679
+ cumulative += net;
680
+ return { date, netLines: cumulative };
681
+ });
682
+ return { weeklyTrend, cumulativeLines };
683
+ }
684
+ function calculateCollaboration(commits) {
685
+ if (commits.length === 0) {
686
+ return emptyCollaborationMetrics();
687
+ }
688
+ const fileAuthors = /* @__PURE__ */ new Map();
689
+ const fileCommits = /* @__PURE__ */ new Map();
690
+ for (const commit of commits) {
691
+ for (const file of commit.files) {
692
+ const authors = fileAuthors.get(file.path) || /* @__PURE__ */ new Set();
693
+ authors.add(commit.email.toLowerCase());
694
+ fileAuthors.set(file.path, authors);
695
+ fileCommits.set(file.path, (fileCommits.get(file.path) || 0) + 1);
696
+ }
697
+ }
698
+ const soloFiles = [];
699
+ const collaborationHotspots = [];
700
+ for (const [path, authors] of fileAuthors) {
701
+ const commitCount = fileCommits.get(path) || 0;
702
+ if (authors.size === 1 && commitCount >= 3) {
703
+ soloFiles.push({
704
+ path,
705
+ author: Array.from(authors)[0],
706
+ commits: commitCount
707
+ });
708
+ } else if (authors.size >= 2 && commitCount >= 5) {
709
+ collaborationHotspots.push({
710
+ path,
711
+ authorCount: authors.size,
712
+ totalCommits: commitCount
713
+ });
714
+ }
715
+ }
716
+ return {
717
+ soloFiles: soloFiles.sort((a, b) => b.commits - a.commits).slice(0, 10),
718
+ collaborationHotspots: collaborationHotspots.sort((a, b) => b.totalCommits - a.totalCommits).slice(0, 10)
719
+ };
720
+ }
721
+ function calculateMessageStats(commits) {
722
+ if (commits.length === 0) {
723
+ return emptyMessageStats();
724
+ }
725
+ const typeDistribution = {};
726
+ let totalLength = 0;
727
+ const typeRegex = /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:/i;
728
+ for (const commit of commits) {
729
+ totalLength += commit.message.length;
730
+ const match = commit.message.match(typeRegex);
731
+ if (match) {
732
+ const type = match[1].toLowerCase();
733
+ typeDistribution[type] = (typeDistribution[type] || 0) + 1;
734
+ } else {
735
+ typeDistribution["other"] = (typeDistribution["other"] || 0) + 1;
736
+ }
737
+ }
738
+ return {
739
+ typeDistribution,
740
+ avgMessageLength: totalLength / commits.length
741
+ };
742
+ }
743
+ function calculateAuthorFileTypeContributions(commits) {
744
+ if (commits.length === 0) {
745
+ return [];
746
+ }
747
+ const contributionMap = /* @__PURE__ */ new Map();
748
+ const uniqueFilesMap = /* @__PURE__ */ new Map();
749
+ for (const commit of commits) {
750
+ for (const file of commit.files) {
751
+ const ext = extname(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
752
+ const key = `${commit.email.toLowerCase()}|||${ext}`;
753
+ let contribution = contributionMap.get(key);
754
+ if (!contribution) {
755
+ contribution = {
756
+ author: commit.author,
757
+ email: commit.email,
758
+ extension: ext,
759
+ linesAdded: 0,
760
+ linesDeleted: 0,
761
+ commits: 0,
762
+ fileCount: 0
763
+ };
764
+ contributionMap.set(key, contribution);
765
+ uniqueFilesMap.set(key, /* @__PURE__ */ new Set());
766
+ }
767
+ contribution.linesAdded += file.added;
768
+ contribution.linesDeleted += file.deleted;
769
+ uniqueFilesMap.get(key).add(file.path);
770
+ }
771
+ }
772
+ const commitCountMap = /* @__PURE__ */ new Map();
773
+ for (const commit of commits) {
774
+ for (const file of commit.files) {
775
+ const ext = extname(file.path).toLowerCase() || "(\u65E0\u6269\u5C55\u540D)";
776
+ const key = `${commit.email.toLowerCase()}|||${ext}`;
777
+ if (!commitCountMap.has(key)) {
778
+ commitCountMap.set(key, /* @__PURE__ */ new Set());
779
+ }
780
+ commitCountMap.get(key).add(commit.hash);
781
+ }
782
+ }
783
+ for (const [key, contribution] of contributionMap) {
784
+ contribution.commits = commitCountMap.get(key)?.size || 0;
785
+ contribution.fileCount = uniqueFilesMap.get(key)?.size || 0;
786
+ }
787
+ return Array.from(contributionMap.values()).sort((a, b) => {
788
+ const totalA = a.linesAdded + a.linesDeleted;
789
+ const totalB = b.linesAdded + b.linesDeleted;
790
+ return totalB - totalA;
791
+ }).slice(0, 20);
792
+ }
793
+ function emptyQualityMetrics() {
794
+ return {
795
+ avgFilesPerCommit: 0,
796
+ avgLinesPerCommit: 0,
797
+ churnRate: 0,
798
+ hotFiles: []
799
+ };
800
+ }
801
+ function emptyTimePatterns() {
802
+ return {
803
+ weekdayDistribution: new Array(7).fill(0),
804
+ weekendCommits: 0,
805
+ avgCommitInterval: 0,
806
+ longestStreak: 0,
807
+ currentStreak: 0
808
+ };
809
+ }
810
+ function emptyTrendData() {
811
+ return {
812
+ weeklyTrend: [],
813
+ cumulativeLines: []
814
+ };
815
+ }
816
+ function emptyCollaborationMetrics() {
817
+ return {
818
+ soloFiles: [],
819
+ collaborationHotspots: []
820
+ };
821
+ }
822
+ function emptyMessageStats() {
823
+ return {
824
+ typeDistribution: {},
825
+ avgMessageLength: 0
826
+ };
827
+ }
828
+
829
+ // src/analyzer/advanced/team-health.ts
830
+ function calculateTeamHealth(commits) {
831
+ if (commits.length === 0) {
832
+ return emptyTeamHealth();
833
+ }
834
+ const authorFiles = buildAuthorFilesMap(commits);
835
+ const fileAuthors = buildFileAuthorsMap(commits);
836
+ if (fileAuthors.size === 0) {
837
+ return emptyTeamHealth();
838
+ }
839
+ const authorScores = calculateAuthorScores(authorFiles, fileAuthors);
840
+ const criticalAuthors = authorScores.filter((a) => a.knowledgeScore > 10);
841
+ const totalUniqueFiles = criticalAuthors.reduce(
842
+ (sum, a) => sum + a.uniqueFiles.length,
843
+ 0
844
+ );
845
+ const knowledgeDistribution = 1 - totalUniqueFiles / fileAuthors.size;
846
+ const busFactor = criticalAuthors.length;
847
+ const riskLevel = busFactor === 1 ? "high" : busFactor <= 3 ? "medium" : "low";
848
+ return {
849
+ busFactor,
850
+ criticalAuthors,
851
+ knowledgeDistribution,
852
+ riskLevel
853
+ };
854
+ }
855
+ function buildAuthorFilesMap(commits) {
856
+ const map = /* @__PURE__ */ new Map();
857
+ for (const commit of commits) {
858
+ const authorKey = `${commit.author} <${commit.email}>`;
859
+ if (!map.has(authorKey)) {
860
+ map.set(authorKey, /* @__PURE__ */ new Set());
861
+ }
862
+ const files = map.get(authorKey);
863
+ for (const file of commit.files) {
864
+ files.add(file.path);
865
+ }
866
+ }
867
+ return map;
868
+ }
869
+ function buildFileAuthorsMap(commits) {
870
+ const map = /* @__PURE__ */ new Map();
871
+ for (const commit of commits) {
872
+ const authorKey = `${commit.author} <${commit.email}>`;
873
+ for (const file of commit.files) {
874
+ if (!map.has(file.path)) {
875
+ map.set(file.path, /* @__PURE__ */ new Map());
876
+ }
877
+ const authors = map.get(file.path);
878
+ authors.set(authorKey, (authors.get(authorKey) || 0) + 1);
879
+ }
880
+ }
881
+ return map;
882
+ }
883
+ function calculateAuthorScores(authorFiles, fileAuthors) {
884
+ const scores = [];
885
+ for (const [authorKey, files] of authorFiles) {
886
+ const uniqueFiles = [];
887
+ const dominantFiles = [];
888
+ for (const filePath of files) {
889
+ const authors = fileAuthors.get(filePath);
890
+ const authorCount = authors.size;
891
+ if (authorCount === 1) {
892
+ uniqueFiles.push(filePath);
893
+ continue;
894
+ }
895
+ const authorCommits = authors.get(authorKey) || 0;
896
+ const totalCommits = Array.from(authors.values()).reduce((a, b) => a + b, 0);
897
+ if (authorCommits / totalCommits > 0.5) {
898
+ dominantFiles.push(filePath);
899
+ }
900
+ }
901
+ const knowledgeScore = (uniqueFiles.length * 2 + dominantFiles.length) / fileAuthors.size * 100;
902
+ const [name, email] = parseAuthorKey(authorKey);
903
+ scores.push({
904
+ name,
905
+ email,
906
+ uniqueFiles,
907
+ dominantFiles,
908
+ knowledgeScore
909
+ });
910
+ }
911
+ return scores;
912
+ }
913
+ function parseAuthorKey(authorKey) {
914
+ const match = authorKey.match(/^(.+) <(.+)>$/);
915
+ if (match) {
916
+ return [match[1], match[2]];
917
+ }
918
+ return [authorKey, ""];
919
+ }
920
+ function emptyTeamHealth() {
921
+ return {
922
+ busFactor: 0,
923
+ criticalAuthors: [],
924
+ knowledgeDistribution: 1,
925
+ riskLevel: "low"
926
+ };
927
+ }
928
+
929
+ // src/analyzer/advanced/code-stability.ts
930
+ function calculateStability(commits) {
931
+ if (commits.length === 0) {
932
+ return emptyStability();
933
+ }
934
+ const fileStats = /* @__PURE__ */ new Map();
935
+ for (const commit of commits) {
936
+ for (const file of commit.files) {
937
+ const stat2 = fileStats.get(file.path) || { added: 0, deleted: 0, modifyCount: 0 };
938
+ stat2.added += file.added;
939
+ stat2.deleted += file.deleted;
940
+ stat2.modifyCount++;
941
+ fileStats.set(file.path, stat2);
942
+ }
943
+ }
944
+ const fileChurnRate = Array.from(fileStats.entries()).map(([path, { added, deleted, modifyCount }]) => ({
945
+ path,
946
+ added,
947
+ deleted,
948
+ churnRate: added > 0 ? deleted / added : 0,
949
+ modifyCount,
950
+ isUnstable: deleted / Math.max(added, 1) > 0.5
951
+ })).sort((a, b) => b.churnRate - a.churnRate).slice(0, 20);
952
+ const dirStats = /* @__PURE__ */ new Map();
953
+ for (const commit of commits) {
954
+ for (const file of commit.files) {
955
+ const dir = getTopDirectory2(file.path);
956
+ const stat2 = dirStats.get(dir) || { added: 0, deleted: 0, files: /* @__PURE__ */ new Set() };
957
+ stat2.added += file.added;
958
+ stat2.deleted += file.deleted;
959
+ stat2.files.add(file.path);
960
+ dirStats.set(dir, stat2);
961
+ }
962
+ }
963
+ const directoryChurnRate = Array.from(dirStats.entries()).map(([path, { added, deleted, files }]) => ({
964
+ path,
965
+ churnRate: added > 0 ? deleted / added : 0,
966
+ totalChanges: added + deleted,
967
+ fileCount: files.size
968
+ })).sort((a, b) => b.churnRate - a.churnRate).slice(0, 10);
969
+ const revertCommits = commits.filter((c) => /revert|rollback/i.test(c.message)).length;
970
+ const revertRate = revertCommits / commits.length;
971
+ const fixCommits = commits.filter((c) => /^fix(\(.+\))?:/i.test(c.message)).length;
972
+ const fixCommitRate = fixCommits / commits.length;
973
+ const avgChurnRate = fileChurnRate.length > 0 ? fileChurnRate.reduce((sum, f) => sum + f.churnRate, 0) / fileChurnRate.length : 0;
974
+ const stabilityScore = Math.max(
975
+ 0,
976
+ Math.round(100 - (avgChurnRate * 50 + revertRate * 100 + fixCommitRate * 50))
977
+ );
978
+ return {
979
+ fileChurnRate,
980
+ directoryChurnRate,
981
+ revertRate: Math.round(revertRate * 100) / 100,
982
+ fixCommitRate: Math.round(fixCommitRate * 100) / 100,
983
+ stabilityScore
984
+ };
985
+ }
986
+ function getTopDirectory2(filePath) {
987
+ const parts = filePath.split("/");
988
+ return parts.length > 1 ? parts[0] : "(\u6839\u76EE\u5F55)";
989
+ }
990
+ function emptyStability() {
991
+ return {
992
+ fileChurnRate: [],
993
+ directoryChurnRate: [],
994
+ revertRate: 0,
995
+ fixCommitRate: 0,
996
+ stabilityScore: 100
997
+ };
998
+ }
999
+
1000
+ // src/analyzer/advanced/work-pressure.ts
1001
+ function calculateWorkPressure(commits) {
1002
+ if (commits.length === 0) {
1003
+ return emptyWorkPressure();
1004
+ }
1005
+ let lateNightCommits = 0;
1006
+ let earlyMorningCommits = 0;
1007
+ let weekendCommits = 0;
1008
+ const holidayMap = /* @__PURE__ */ new Map();
1009
+ const holidays = getHolidays();
1010
+ for (const commit of commits) {
1011
+ const hour = commit.date.getHours();
1012
+ const day = commit.date.getDay();
1013
+ const dateKey = formatDate(commit.date);
1014
+ if (hour >= 23 || hour < 2) {
1015
+ lateNightCommits++;
1016
+ }
1017
+ if (hour >= 2 && hour < 6) {
1018
+ earlyMorningCommits++;
1019
+ }
1020
+ if (day === 0 || day === 6) {
1021
+ weekendCommits++;
1022
+ }
1023
+ const holiday = holidays.get(dateKey);
1024
+ if (holiday) {
1025
+ const entry = holidayMap.get(dateKey) || { name: holiday, commits: 0 };
1026
+ entry.commits++;
1027
+ holidayMap.set(dateKey, entry);
1028
+ }
1029
+ }
1030
+ const holidayCommits = Array.from(holidayMap.entries()).map(([date, { name, commits: commits2 }]) => ({ date, holidayName: name, commits: commits2 })).sort((a, b) => b.commits - a.commits);
1031
+ const holidayTotal = holidayCommits.reduce((sum, h) => sum + h.commits, 0);
1032
+ const offHoursCount = lateNightCommits + earlyMorningCommits + weekendCommits + holidayTotal;
1033
+ const offHoursRate = offHoursCount / commits.length;
1034
+ const lateNightWeight = lateNightCommits / commits.length * 40;
1035
+ const earlyMorningWeight = earlyMorningCommits / commits.length * 30;
1036
+ const weekendWeight = weekendCommits / commits.length * 20;
1037
+ const holidayWeight = (holidayCommits.length > 0 ? 1 : 0) * 10;
1038
+ const pressureScore = Math.round(lateNightWeight + earlyMorningWeight + weekendWeight + holidayWeight);
1039
+ return {
1040
+ lateNightCommits,
1041
+ earlyMorningCommits,
1042
+ weekendCommits,
1043
+ holidayCommits,
1044
+ pressureScore,
1045
+ offHoursRate: Math.round(offHoursRate * 1e3) / 1e3
1046
+ };
1047
+ }
1048
+ function emptyWorkPressure() {
1049
+ return {
1050
+ lateNightCommits: 0,
1051
+ earlyMorningCommits: 0,
1052
+ weekendCommits: 0,
1053
+ holidayCommits: [],
1054
+ pressureScore: 0,
1055
+ offHoursRate: 0
1056
+ };
1057
+ }
1058
+ function getHolidays() {
1059
+ const holidays = /* @__PURE__ */ new Map();
1060
+ const dates2024 = [
1061
+ ["2024-01-01", "\u5143\u65E6"],
1062
+ ["2024-02-10", "\u6625\u8282"],
1063
+ ["2024-02-11", "\u6625\u8282"],
1064
+ ["2024-02-12", "\u6625\u8282"],
1065
+ ["2024-04-04", "\u6E05\u660E\u8282"],
1066
+ ["2024-04-05", "\u6E05\u660E\u8282"],
1067
+ ["2024-04-06", "\u6E05\u660E\u8282"],
1068
+ ["2024-05-01", "\u52B3\u52A8\u8282"],
1069
+ ["2024-05-02", "\u52B3\u52A8\u8282"],
1070
+ ["2024-05-03", "\u52B3\u52A8\u8282"],
1071
+ ["2024-06-10", "\u7AEF\u5348\u8282"],
1072
+ ["2024-09-15", "\u4E2D\u79CB\u8282"],
1073
+ ["2024-09-16", "\u4E2D\u79CB\u8282"],
1074
+ ["2024-09-17", "\u4E2D\u79CB\u8282"],
1075
+ ["2024-10-01", "\u56FD\u5E86\u8282"],
1076
+ ["2024-10-02", "\u56FD\u5E86\u8282"],
1077
+ ["2024-10-03", "\u56FD\u5E86\u8282"]
1078
+ ];
1079
+ const dates2025 = [
1080
+ ["2025-01-01", "\u5143\u65E6"],
1081
+ ["2025-01-29", "\u6625\u8282"],
1082
+ ["2025-01-30", "\u6625\u8282"],
1083
+ ["2025-01-31", "\u6625\u8282"],
1084
+ ["2025-04-04", "\u6E05\u660E\u8282"],
1085
+ ["2025-04-05", "\u6E05\u660E\u8282"],
1086
+ ["2025-04-06", "\u6E05\u660E\u8282"],
1087
+ ["2025-05-01", "\u52B3\u52A8\u8282"],
1088
+ ["2025-05-02", "\u52B3\u52A8\u8282"],
1089
+ ["2025-05-03", "\u52B3\u52A8\u8282"],
1090
+ ["2025-05-31", "\u7AEF\u5348\u8282"],
1091
+ ["2025-10-01", "\u56FD\u5E86\u8282"],
1092
+ ["2025-10-02", "\u56FD\u5E86\u8282"],
1093
+ ["2025-10-06", "\u4E2D\u79CB\u8282"]
1094
+ ];
1095
+ const dates2026 = [
1096
+ ["2026-01-01", "\u5143\u65E6"],
1097
+ ["2026-02-17", "\u6625\u8282"],
1098
+ ["2026-02-18", "\u6625\u8282"],
1099
+ ["2026-02-19", "\u6625\u8282"],
1100
+ ["2026-04-05", "\u6E05\u660E\u8282"],
1101
+ ["2026-05-01", "\u52B3\u52A8\u8282"],
1102
+ ["2026-06-19", "\u7AEF\u5348\u8282"],
1103
+ ["2026-09-25", "\u4E2D\u79CB\u8282"],
1104
+ ["2026-10-01", "\u56FD\u5E86\u8282"],
1105
+ ["2026-10-02", "\u56FD\u5E86\u8282"]
1106
+ ];
1107
+ [...dates2024, ...dates2025, ...dates2026].forEach(([date, name]) => {
1108
+ holidays.set(date, name);
1109
+ });
1110
+ return holidays;
1111
+ }
1112
+ function formatDate(date) {
1113
+ return date.toISOString().split("T")[0];
1114
+ }
1115
+
1116
+ // src/analyzer/advanced/contributor-churn.ts
1117
+ function calculateContributorChurn(commits) {
1118
+ if (commits.length === 0) {
1119
+ return emptyContributorChurn();
1120
+ }
1121
+ const authorLastCommit = /* @__PURE__ */ new Map();
1122
+ for (const commit of commits) {
1123
+ const key = commit.email.toLowerCase();
1124
+ const existing = authorLastCommit.get(key);
1125
+ if (!existing) {
1126
+ authorLastCommit.set(key, {
1127
+ name: commit.author,
1128
+ email: commit.email,
1129
+ lastDate: commit.date,
1130
+ firstDate: commit.date,
1131
+ totalCommits: 1
1132
+ });
1133
+ } else {
1134
+ if (commit.date > existing.lastDate) {
1135
+ existing.lastDate = commit.date;
1136
+ }
1137
+ if (commit.date < existing.firstDate) {
1138
+ existing.firstDate = commit.date;
1139
+ }
1140
+ existing.totalCommits++;
1141
+ }
1142
+ }
1143
+ const now = /* @__PURE__ */ new Date();
1144
+ const active = [];
1145
+ const occasional = [];
1146
+ const dormant = [];
1147
+ const lost = [];
1148
+ const newJoiners = [];
1149
+ for (const [, author] of authorLastCommit) {
1150
+ const daysSinceLast = Math.floor((now.getTime() - author.lastDate.getTime()) / (1e3 * 60 * 60 * 24));
1151
+ const daysSinceFirst = Math.floor((now.getTime() - author.firstDate.getTime()) / (1e3 * 60 * 60 * 24));
1152
+ const detail = {
1153
+ name: author.name,
1154
+ email: author.email,
1155
+ lastCommitDate: author.lastDate,
1156
+ daysSinceLastCommit: daysSinceLast,
1157
+ totalCommits: author.totalCommits
1158
+ };
1159
+ if (daysSinceFirst < 30) {
1160
+ newJoiners.push(detail);
1161
+ }
1162
+ if (daysSinceLast < 30) {
1163
+ active.push(detail);
1164
+ } else if (daysSinceLast < 90) {
1165
+ occasional.push(detail);
1166
+ } else if (daysSinceLast < 180) {
1167
+ dormant.push(detail);
1168
+ } else {
1169
+ lost.push(detail);
1170
+ }
1171
+ }
1172
+ const totalAuthors = authorLastCommit.size;
1173
+ const churnRate = totalAuthors > 0 ? lost.length / totalAuthors : 0;
1174
+ const retentionRate = totalAuthors > 0 ? active.length / totalAuthors : 0;
1175
+ const growthRate = totalAuthors > 0 ? newJoiners.length / totalAuthors : 0;
1176
+ return {
1177
+ active: active.sort((a, b) => b.totalCommits - a.totalCommits),
1178
+ occasional: occasional.sort((a, b) => a.daysSinceLastCommit - b.daysSinceLastCommit),
1179
+ dormant: dormant.sort((a, b) => a.daysSinceLastCommit - b.daysSinceLastCommit),
1180
+ lost: lost.sort((a, b) => b.totalCommits - a.totalCommits),
1181
+ newJoiners: newJoiners.sort((a, b) => a.daysSinceLastCommit - b.daysSinceLastCommit),
1182
+ churnRate: Math.round(churnRate * 1e3) / 1e3,
1183
+ retentionRate: Math.round(retentionRate * 1e3) / 1e3,
1184
+ growthRate: Math.round(growthRate * 1e3) / 1e3
1185
+ };
1186
+ }
1187
+ function emptyContributorChurn() {
1188
+ return {
1189
+ active: [],
1190
+ occasional: [],
1191
+ dormant: [],
1192
+ lost: [],
1193
+ newJoiners: [],
1194
+ churnRate: 0,
1195
+ retentionRate: 0,
1196
+ growthRate: 0
1197
+ };
1198
+ }
1199
+
1200
+ // src/analyzer/advanced/collaboration.ts
1201
+ var MAX_FILES_PER_COMMIT = 50;
1202
+ function calculateAdvancedCollaboration(commits) {
1203
+ if (commits.length === 0) {
1204
+ return emptyCollaboration();
1205
+ }
1206
+ const fileModifyCount = /* @__PURE__ */ new Map();
1207
+ for (const commit of commits) {
1208
+ commit.files.forEach((f) => {
1209
+ fileModifyCount.set(f.path, (fileModifyCount.get(f.path) || 0) + 1);
1210
+ });
1211
+ }
1212
+ const tightCoupling = detectTightCoupling(commits, fileModifyCount);
1213
+ const pairProgramming = detectPairProgramming(commits);
1214
+ const avgCoupling = tightCoupling.length > 0 ? tightCoupling.reduce((sum, p) => sum + p.coupling, 0) / tightCoupling.length : 0;
1215
+ const couplingScore = Math.min(100, Math.round(avgCoupling * 100));
1216
+ return {
1217
+ tightCoupling,
1218
+ frequentPairs: [],
1219
+ // 简化版:跳过 24h 窗口分析
1220
+ pairProgramming,
1221
+ couplingScore
1222
+ };
1223
+ }
1224
+ function detectTightCoupling(commits, fileModifyCount) {
1225
+ const coCommitPairs = /* @__PURE__ */ new Map();
1226
+ for (const commit of commits) {
1227
+ const files = commit.files.map((f) => f.path).slice(0, MAX_FILES_PER_COMMIT).sort();
1228
+ for (let i = 0; i < files.length; i++) {
1229
+ for (let j = i + 1; j < files.length; j++) {
1230
+ const pairKey = `${files[i]}|||${files[j]}`;
1231
+ const existing = coCommitPairs.get(pairKey) || {
1232
+ count: 0,
1233
+ file1: files[i],
1234
+ file2: files[j]
1235
+ };
1236
+ existing.count++;
1237
+ coCommitPairs.set(pairKey, existing);
1238
+ }
1239
+ }
1240
+ }
1241
+ const tightCoupling = Array.from(coCommitPairs.values()).map(({ file1, file2, count }) => {
1242
+ const count1 = fileModifyCount.get(file1) || 1;
1243
+ const count2 = fileModifyCount.get(file2) || 1;
1244
+ const coupling = count / Math.min(count1, count2);
1245
+ return {
1246
+ file1,
1247
+ file2,
1248
+ coOccurrence: count,
1249
+ coupling
1250
+ };
1251
+ }).filter((p) => p.coOccurrence >= 3).sort((a, b) => b.coupling - a.coupling).slice(0, 20);
1252
+ return tightCoupling;
1253
+ }
1254
+ function detectPairProgramming(commits) {
1255
+ const fileAuthors = /* @__PURE__ */ new Map();
1256
+ for (const commit of commits) {
1257
+ for (const file of commit.files) {
1258
+ if (!fileAuthors.has(file.path)) {
1259
+ fileAuthors.set(file.path, /* @__PURE__ */ new Map());
1260
+ }
1261
+ const authorMap = fileAuthors.get(file.path);
1262
+ const email = commit.email.toLowerCase();
1263
+ authorMap.set(email, (authorMap.get(email) || 0) + 1);
1264
+ }
1265
+ }
1266
+ const authorPairMap = /* @__PURE__ */ new Map();
1267
+ for (const [filePath, authorMap] of fileAuthors) {
1268
+ if (authorMap.size >= 2) {
1269
+ const sortedAuthors = Array.from(authorMap.entries()).sort((a, b) => b[1] - a[1]).slice(0, 2);
1270
+ if (sortedAuthors.length === 2) {
1271
+ const [a1, a2] = [sortedAuthors[0][0], sortedAuthors[1][0]].sort();
1272
+ const pairKey = `${a1}|||${a2}`;
1273
+ const existing = authorPairMap.get(pairKey) || {
1274
+ author1: a1,
1275
+ author2: a2,
1276
+ files: /* @__PURE__ */ new Set(),
1277
+ count: 0
1278
+ };
1279
+ existing.files.add(filePath);
1280
+ existing.count++;
1281
+ authorPairMap.set(pairKey, existing);
1282
+ }
1283
+ }
1284
+ }
1285
+ const pairProgramming = Array.from(authorPairMap.values()).filter((p) => p.files.size >= 3).map((p) => ({
1286
+ author1: p.author1,
1287
+ author2: p.author2,
1288
+ sharedFiles: Array.from(p.files),
1289
+ collaborationCount: p.count
1290
+ })).sort((a, b) => b.collaborationCount - a.collaborationCount).slice(0, 10);
1291
+ return pairProgramming;
1292
+ }
1293
+ function emptyCollaboration() {
1294
+ return {
1295
+ tightCoupling: [],
1296
+ frequentPairs: [],
1297
+ pairProgramming: [],
1298
+ couplingScore: 0
1299
+ };
1300
+ }
1301
+
1302
+ // src/analyzer/advanced/index.ts
1303
+ function calculateAdvancedStats(commits) {
1304
+ return {
1305
+ teamHealth: calculateTeamHealth(commits),
1306
+ stability: calculateStability(commits),
1307
+ workPressure: calculateWorkPressure(commits),
1308
+ contributorChurn: calculateContributorChurn(commits),
1309
+ advancedCollaboration: calculateAdvancedCollaboration(commits)
1310
+ };
1311
+ }
1312
+
1313
+ // src/analyzer/index.ts
1314
+ async function analyzeRepos(options) {
1315
+ const { repos, timeRange, author } = options;
1316
+ const spinner = ora2("\u5206\u6790\u63D0\u4EA4\u8BB0\u5F55...").start();
1317
+ const allStats = [];
1318
+ for (let i = 0; i < repos.length; i++) {
1319
+ const repo = repos[i];
1320
+ spinner.text = `\u5206\u6790\u63D0\u4EA4\u8BB0\u5F55 (${i + 1}/${repos.length}) - ${repo.name}`;
1321
+ try {
1322
+ const commits = await parseGitLog(repo.path, timeRange, author);
1323
+ if (commits.length > 1e5) {
1324
+ spinner.info(
1325
+ chalk.yellow(`${repo.name} \u5305\u542B ${commits.length.toLocaleString()} \u6761\u63D0\u4EA4\uFF0C\u5904\u7406\u53EF\u80FD\u9700\u8981\u4E00\u4E9B\u65F6\u95F4...`)
1326
+ );
1327
+ spinner.start();
1328
+ }
1329
+ const stats = calculateStats(commits);
1330
+ const advancedStats = calculateAdvancedStats(commits);
1331
+ const fullStats = {
1332
+ ...stats,
1333
+ ...advancedStats
1334
+ };
1335
+ allStats.push(fullStats);
1336
+ } catch (error) {
1337
+ spinner.warn(
1338
+ chalk.yellow(
1339
+ `\u8DF3\u8FC7\u4ED3\u5E93 ${repo.name}: ${error instanceof Error ? error.message : "\u672A\u77E5\u9519\u8BEF"}`
1340
+ )
1341
+ );
1342
+ spinner.start();
1343
+ }
1344
+ }
1345
+ const merged = mergeStats(allStats);
1346
+ spinner.succeed(
1347
+ `\u5206\u6790\u5B8C\u6210: ${merged.totalCommits.toLocaleString()} \u6761\u63D0\u4EA4, ${merged.authors.length} \u4F4D\u4F5C\u8005, +${merged.linesAdded.toLocaleString()} / -${merged.linesDeleted.toLocaleString()} \u884C`
1348
+ );
1349
+ return merged;
1350
+ }
1351
+
1352
+ // src/reporter/index.ts
1353
+ import { writeFile } from "fs/promises";
1354
+ import { resolve as resolve2 } from "path";
1355
+ import chalk2 from "chalk";
1356
+ import ora3 from "ora";
1357
+ import open from "open";
1358
+
1359
+ // src/reporter/html-builder.ts
1360
+ import { readFile as readFile2 } from "fs/promises";
1361
+ import { resolve, dirname } from "path";
1362
+ import { fileURLToPath } from "url";
1363
+ async function buildHtml(stats, options) {
1364
+ const template = await loadTemplate();
1365
+ const reportData = {
1366
+ stats: serializeStats(stats),
1367
+ generatedAt: (/* @__PURE__ */ new Date()).toLocaleString("zh-CN"),
1368
+ timeRange: options.timeRange ? {
1369
+ from: options.timeRange.from.toISOString().split("T")[0],
1370
+ to: options.timeRange.to.toISOString().split("T")[0]
1371
+ } : null,
1372
+ repos: options.repoNames
1373
+ };
1374
+ const jsonData = JSON.stringify(reportData).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
1375
+ return template.replace("__REPORT_DATA__", jsonData);
1376
+ }
1377
+ function serializeStats(stats) {
1378
+ const serializeAuthorDetail = (author) => ({
1379
+ ...author,
1380
+ lastCommitDate: author.lastCommitDate.toISOString()
1381
+ });
1382
+ return {
1383
+ ...stats,
1384
+ firstCommitDate: stats.firstCommitDate.toISOString(),
1385
+ lastCommitDate: stats.lastCommitDate.toISOString(),
1386
+ authors: stats.authors.map((a) => ({
1387
+ ...a,
1388
+ lastActiveDate: a.lastActiveDate.toISOString()
1389
+ })),
1390
+ contributorChurn: stats.contributorChurn ? {
1391
+ ...stats.contributorChurn,
1392
+ active: stats.contributorChurn.active.map(serializeAuthorDetail),
1393
+ occasional: stats.contributorChurn.occasional.map(serializeAuthorDetail),
1394
+ dormant: stats.contributorChurn.dormant.map(serializeAuthorDetail),
1395
+ lost: stats.contributorChurn.lost.map(serializeAuthorDetail),
1396
+ newJoiners: stats.contributorChurn.newJoiners.map(serializeAuthorDetail)
1397
+ } : void 0
1398
+ };
1399
+ }
1400
+ async function loadTemplate() {
1401
+ const currentDir = dirname(fileURLToPath(import.meta.url));
1402
+ const possiblePaths = [
1403
+ resolve(currentDir, "../templates/report.html"),
1404
+ resolve(currentDir, "../../templates/report.html"),
1405
+ resolve(currentDir, "../../../templates/report.html")
1406
+ ];
1407
+ for (const templatePath of possiblePaths) {
1408
+ try {
1409
+ return await readFile2(templatePath, "utf-8");
1410
+ } catch {
1411
+ }
1412
+ }
1413
+ throw new Error("\u65E0\u6CD5\u627E\u5230 HTML \u6A21\u677F\u6587\u4EF6");
1414
+ }
1415
+
1416
+ // src/reporter/index.ts
1417
+ async function generateReport(stats, options) {
1418
+ const spinner = ora3("\u751F\u6210\u62A5\u544A...").start();
1419
+ try {
1420
+ const html = await buildHtml(stats, options);
1421
+ const outputPath = resolve2(process.cwd(), options.outputPath);
1422
+ await writeFile(outputPath, html, "utf-8");
1423
+ spinner.succeed(`\u62A5\u544A\u5DF2\u751F\u6210: ${chalk2.cyan(outputPath)}`);
1424
+ if (options.autoOpen) {
1425
+ await open(outputPath);
1426
+ console.log(chalk2.green("\u2713 \u5DF2\u5728\u6D4F\u89C8\u5668\u4E2D\u6253\u5F00"));
1427
+ }
1428
+ } catch (error) {
1429
+ spinner.fail("\u751F\u6210\u62A5\u544A\u5931\u8D25");
1430
+ throw error;
1431
+ }
1432
+ }
1433
+
1434
+ // src/cli/time-utils.ts
1435
+ var PERIOD_REGEX = /^(\d+)(d|m|y)$/;
1436
+ function parsePeriod(period) {
1437
+ if (period === "all") {
1438
+ return null;
1439
+ }
1440
+ const match = PERIOD_REGEX.exec(period);
1441
+ if (!match) {
1442
+ throw new Error(`\u65E0\u6548\u7684\u65F6\u95F4\u9884\u8BBE: "${period}"\uFF0C\u652F\u6301\u683C\u5F0F: 7d, 1m, 3m, 6m, 1y, all`);
1443
+ }
1444
+ const amount = parseInt(match[1], 10);
1445
+ const unit = match[2];
1446
+ const to = /* @__PURE__ */ new Date();
1447
+ const from = /* @__PURE__ */ new Date();
1448
+ switch (unit) {
1449
+ case "d":
1450
+ from.setDate(from.getDate() - amount);
1451
+ break;
1452
+ case "m":
1453
+ from.setMonth(from.getMonth() - amount);
1454
+ break;
1455
+ case "y":
1456
+ from.setFullYear(from.getFullYear() - amount);
1457
+ break;
1458
+ }
1459
+ return { from, to };
1460
+ }
1461
+ function parseDate(dateStr) {
1462
+ const date = new Date(dateStr);
1463
+ if (isNaN(date.getTime())) {
1464
+ throw new Error(`\u65E0\u6548\u7684\u65E5\u671F\u683C\u5F0F: "${dateStr}"\uFF0C\u8BF7\u4F7F\u7528 YYYY-MM-DD \u683C\u5F0F`);
1465
+ }
1466
+ return date;
1467
+ }
1468
+ function resolveTimeRange(opts) {
1469
+ if (opts.from || opts.to) {
1470
+ const to = opts.to ? parseDate(opts.to) : /* @__PURE__ */ new Date();
1471
+ const from = opts.from ? parseDate(opts.from) : (() => {
1472
+ const d = new Date(to);
1473
+ d.setMonth(d.getMonth() - 3);
1474
+ return d;
1475
+ })();
1476
+ if (from > to) {
1477
+ throw new Error("\u8D77\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F");
1478
+ }
1479
+ return { from, to };
1480
+ }
1481
+ return parsePeriod(opts.period);
1482
+ }
1483
+
1484
+ // src/cli/index.ts
1485
+ var program = new Command();
1486
+ program.name("commit-report").description("Git \u63D0\u4EA4\u7EDF\u8BA1\u5DE5\u5177\uFF0C\u751F\u6210\u53EF\u89C6\u5316 HTML \u62A5\u544A").version("1.0.0").argument("[directory]", "\u8981\u626B\u63CF\u7684\u76EE\u5F55\u8DEF\u5F84", process.cwd()).option("-p, --period <period>", "\u65F6\u95F4\u9884\u8BBE (7d/1m/3m/6m/1y/all)", "all").option("-f, --from <date>", "\u8D77\u59CB\u65E5\u671F (YYYY-MM-DD)").option("-t, --to <date>", "\u7ED3\u675F\u65E5\u671F (YYYY-MM-DD)").option("-a, --author <name>", "\u8FC7\u6EE4\u4F5C\u8005").option("-o, --output <file>", "\u8F93\u51FA\u6587\u4EF6\u540D", "commit-report.html").option("--no-open", "\u4E0D\u81EA\u52A8\u6253\u5F00\u6D4F\u89C8\u5668").option("-d, --depth <number>", "\u6700\u5927\u626B\u63CF\u6DF1\u5EA6", "20").action(async (directory, opts) => {
1487
+ try {
1488
+ await run(directory, opts);
1489
+ } catch (error) {
1490
+ if (error instanceof Error && error.message === "USER_CANCEL") {
1491
+ console.log(chalk3.yellow("\n\u5DF2\u53D6\u6D88\u64CD\u4F5C"));
1492
+ process.exit(0);
1493
+ }
1494
+ console.error(chalk3.red(`
1495
+ \u9519\u8BEF: ${error instanceof Error ? error.message : String(error)}`));
1496
+ process.exit(1);
1497
+ }
1498
+ });
1499
+ async function run(directory, opts) {
1500
+ await checkGitInstalled();
1501
+ const timeRange = resolveTimeRange(opts);
1502
+ const repos = await scanRepositories({
1503
+ targetDir: directory,
1504
+ maxDepth: Number(opts.depth)
1505
+ });
1506
+ if (repos.length === 0) {
1507
+ console.log(chalk3.red("\u672A\u627E\u5230 Git \u4ED3\u5E93"));
1508
+ process.exit(1);
1509
+ }
1510
+ let selectedRepos;
1511
+ if (repos.length === 1) {
1512
+ selectedRepos = repos;
1513
+ console.log(chalk3.cyan(`\u627E\u5230 1 \u4E2A Git \u4ED3\u5E93: ${repos[0].name}`));
1514
+ } else {
1515
+ console.log(chalk3.cyan(`
1516
+ \u627E\u5230 ${repos.length} \u4E2A Git \u4ED3\u5E93:
1517
+ `));
1518
+ const selected = await checkbox({
1519
+ message: "\u9009\u62E9\u8981\u5206\u6790\u7684\u4ED3\u5E93\uFF08\u7A7A\u683C\u9009\u62E9\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
1520
+ choices: repos.map((repo) => ({
1521
+ name: `${repo.name} (${repo.commitCount} commits)`,
1522
+ value: repo.path,
1523
+ checked: true
1524
+ }))
1525
+ });
1526
+ if (selected.length === 0) {
1527
+ console.log(chalk3.yellow("\u672A\u9009\u62E9\u4EFB\u4F55\u4ED3\u5E93"));
1528
+ process.exit(0);
1529
+ }
1530
+ selectedRepos = repos.filter((r) => selected.includes(r.path));
1531
+ }
1532
+ const timeRangeText = timeRange ? `${formatDate2(timeRange.from)} ~ ${formatDate2(timeRange.to)}` : "\u6240\u6709\u63D0\u4EA4";
1533
+ console.log(
1534
+ chalk3.gray(
1535
+ `
1536
+ \u5DF2\u9009\u62E9 ${selectedRepos.length} \u4E2A\u4ED3\u5E93\uFF0C\u65F6\u95F4\u8303\u56F4\uFF1A${timeRangeText}
1537
+ `
1538
+ )
1539
+ );
1540
+ const stats = await analyzeRepos({
1541
+ repos: selectedRepos,
1542
+ timeRange,
1543
+ author: opts.author
1544
+ });
1545
+ if (stats.totalCommits === 0) {
1546
+ console.log(chalk3.yellow("\u8BE5\u65F6\u95F4\u6BB5\u65E0\u63D0\u4EA4\u8BB0\u5F55"));
1547
+ }
1548
+ await generateReport(stats, {
1549
+ outputPath: opts.output,
1550
+ autoOpen: opts.open,
1551
+ timeRange,
1552
+ repoNames: selectedRepos.map((r) => r.name)
1553
+ });
1554
+ }
1555
+ async function checkGitInstalled() {
1556
+ const { execSync: execSync3 } = await import("child_process");
1557
+ try {
1558
+ execSync3("git --version", { stdio: "ignore" });
1559
+ } catch {
1560
+ console.error(chalk3.red("\u8BF7\u5148\u5B89\u88C5 Git"));
1561
+ process.exit(1);
1562
+ }
1563
+ }
1564
+ function formatDate2(date) {
1565
+ return date.toISOString().split("T")[0];
1566
+ }
1567
+ process.on("SIGINT", () => {
1568
+ console.log(chalk3.yellow("\n\u5DF2\u53D6\u6D88\u64CD\u4F5C"));
1569
+ process.exit(0);
1570
+ });
1571
+ program.parse();
1572
+ //# sourceMappingURL=index.js.map