@entro314labs/ai-changelog-generator 3.2.0 → 3.3.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +41 -10
  2. package/ai-changelog-mcp.sh +0 -0
  3. package/ai-changelog.sh +0 -0
  4. package/bin/ai-changelog-dxt.js +0 -0
  5. package/package.json +72 -80
  6. package/src/ai-changelog-generator.js +11 -2
  7. package/src/application/orchestrators/changelog.orchestrator.js +12 -202
  8. package/src/cli.js +4 -5
  9. package/src/domains/ai/ai-analysis.service.js +2 -0
  10. package/src/domains/analysis/analysis.engine.js +758 -5
  11. package/src/domains/changelog/changelog.service.js +711 -13
  12. package/src/domains/changelog/workspace-changelog.service.js +429 -571
  13. package/src/domains/git/commit-tagger.js +552 -0
  14. package/src/domains/git/git-manager.js +357 -0
  15. package/src/domains/git/git.service.js +865 -16
  16. package/src/infrastructure/cli/cli.controller.js +14 -9
  17. package/src/infrastructure/config/configuration.manager.js +24 -2
  18. package/src/infrastructure/interactive/interactive-workflow.service.js +8 -1
  19. package/src/infrastructure/mcp/mcp-server.service.js +35 -11
  20. package/src/infrastructure/providers/core/base-provider.js +1 -1
  21. package/src/infrastructure/providers/implementations/anthropic.js +16 -173
  22. package/src/infrastructure/providers/implementations/azure.js +16 -63
  23. package/src/infrastructure/providers/implementations/dummy.js +13 -16
  24. package/src/infrastructure/providers/implementations/mock.js +13 -26
  25. package/src/infrastructure/providers/implementations/ollama.js +12 -4
  26. package/src/infrastructure/providers/implementations/openai.js +13 -165
  27. package/src/infrastructure/providers/provider-management.service.js +126 -412
  28. package/src/infrastructure/providers/utils/base-provider-helpers.js +11 -0
  29. package/src/shared/utils/cli-ui.js +1 -1
  30. package/src/shared/utils/diff-processor.js +21 -19
  31. package/src/shared/utils/error-classes.js +33 -0
  32. package/src/shared/utils/utils.js +65 -60
  33. package/src/domains/git/git-repository.analyzer.js +0 -678
@@ -31,22 +31,56 @@ export class GitService {
31
31
  const [hash, subject, author, date] = lines[0].split('|')
32
32
  const body = lines.slice(1).join('\n').trim()
33
33
 
34
- // Get files with detailed analysis
35
- const filesCommand = `git show --name-status --pretty=format: ${commitHash}`
36
- const filesOutput = this.gitManager.execGitSafe(filesCommand)
37
- const files = await Promise.all(
38
- filesOutput
39
- .split('\n')
40
- .filter(Boolean)
41
- .map(async (line) => {
42
- const parts = line.split('\t')
43
- if (parts.length < 2) {
44
- return null
45
- }
46
- const [status, filePath] = parts
47
- return await this.analyzeFileChange(commitHash, status, filePath)
48
- })
49
- )
34
+ // Detect merge commits and handle them differently
35
+ let isMergeCommit = subject.toLowerCase().includes('merge')
36
+ if (!isMergeCommit) {
37
+ // Check if commit has multiple parents (more reliable for merge detection)
38
+ try {
39
+ const parents = this.gitManager
40
+ .execGitSafe(`git show --format='%P' --no-patch ${commitHash}`)
41
+ .trim()
42
+ .split(' ')
43
+ isMergeCommit = parents.length > 1
44
+ } catch {
45
+ isMergeCommit = false
46
+ }
47
+ }
48
+
49
+ let files = []
50
+
51
+ if (isMergeCommit) {
52
+ // For merge commits, get the stat summary and create a special analysis
53
+ const statOutput = this.gitManager.execGitSafe(
54
+ `git show --stat --pretty=format: ${commitHash}`
55
+ )
56
+ files = this.processMergeCommitStats(commitHash, statOutput)
57
+
58
+ // Generate comprehensive summary for large merge commits
59
+ if (files.length > 10) {
60
+ const enhancedSummary = this.generateMergeCommitSummary(files, commitHash, subject)
61
+ // Add the enhanced summary to the first file entry for the AI to use
62
+ if (files.length > 0) {
63
+ files[0].enhancedMergeSummary = enhancedSummary
64
+ }
65
+ }
66
+ } else {
67
+ // For regular commits, use the existing approach
68
+ const filesCommand = `git show --name-status --pretty=format: ${commitHash}`
69
+ const filesOutput = this.gitManager.execGitSafe(filesCommand)
70
+ files = await Promise.all(
71
+ filesOutput
72
+ .split('\n')
73
+ .filter(Boolean)
74
+ .map(async (line) => {
75
+ const parts = line.split('\t')
76
+ if (parts.length < 2) {
77
+ return null
78
+ }
79
+ const [status, filePath] = parts
80
+ return await this.analyzeFileChange(commitHash, status, filePath)
81
+ })
82
+ )
83
+ }
50
84
 
51
85
  // Filter out null entries
52
86
  const validFiles = files.filter(Boolean)
@@ -319,4 +353,819 @@ export class GitService {
319
353
  return []
320
354
  }
321
355
  }
356
+
357
+ /**
358
+ * Process merge commit statistics to create meaningful file analysis
359
+ */
360
+ processMergeCommitStats(commitHash, statOutput) {
361
+ if (!statOutput || statOutput.trim() === '') {
362
+ return []
363
+ }
364
+
365
+ // Parse the git stat output to extract file information
366
+ const lines = statOutput.split('\n').filter(Boolean)
367
+ const files = []
368
+
369
+ for (const line of lines) {
370
+ // Look for lines with file changes: "filename | additions +++--- deletions"
371
+ const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)\s*([+\-\s]+)/)
372
+ if (match) {
373
+ const [, filePath, changes, diffSymbols] = match
374
+ const additions = (diffSymbols.match(/\+/g) || []).length
375
+ const deletions = (diffSymbols.match(/-/g) || []).length
376
+
377
+ // Determine file status based on changes
378
+ let status = 'M' // Default to modified
379
+ if (additions > 0 && deletions === 0) {
380
+ status = 'A' // Likely added
381
+ } else if (deletions > 0 && additions === 0) {
382
+ status = 'D' // Likely deleted
383
+ }
384
+
385
+ // Create a meaningful diff summary for the AI
386
+ const diff = this.createMergeCommitDiffSummary(
387
+ filePath,
388
+ parseInt(changes, 10),
389
+ additions,
390
+ deletions,
391
+ commitHash
392
+ )
393
+
394
+ files.push({
395
+ status,
396
+ filePath: filePath.trim(),
397
+ diff,
398
+ beforeContent: '',
399
+ afterContent: '',
400
+ category: categorizeFile(filePath),
401
+ language: detectLanguage(filePath),
402
+ importance: assessFileImportance(filePath, status),
403
+ complexity: { level: changes > 100 ? 'high' : changes > 20 ? 'medium' : 'low' },
404
+ semanticChanges: { patterns: ['merge-commit'] },
405
+ functionalImpact: { level: changes > 50 ? 'high' : 'medium' },
406
+ isMergeCommit: true,
407
+ changeCount: parseInt(changes, 10),
408
+ additions,
409
+ deletions,
410
+ })
411
+ }
412
+ }
413
+
414
+ return files
415
+ }
416
+
417
+ /**
418
+ * Create a meaningful diff summary for merge commits with enhanced categorization
419
+ */
420
+ createMergeCommitDiffSummary(filePath, totalChanges, additions, deletions, commitHash) {
421
+ const changeType =
422
+ additions > deletions ? 'expanded' : deletions > additions ? 'reduced' : 'modified'
423
+
424
+ let summary = `Merge commit changes: ${totalChanges} lines ${changeType}`
425
+
426
+ if (additions > 0 && deletions > 0) {
427
+ summary += ` (${additions} added, ${deletions} removed)`
428
+ } else if (additions > 0) {
429
+ summary += ` (${additions} lines added)`
430
+ } else if (deletions > 0) {
431
+ summary += ` (${deletions} lines removed)`
432
+ }
433
+
434
+ // Enhanced context based on file type and size of changes
435
+ if (filePath.includes('test')) {
436
+ summary += ' - Test infrastructure changes from merge'
437
+ } else if (filePath.includes('config') || filePath.includes('.json')) {
438
+ summary += ' - Configuration and dependency changes from merge'
439
+ } else if (filePath.includes('README') || filePath.includes('.md')) {
440
+ summary += ' - Documentation updates from merge'
441
+ } else if (filePath.includes('src/domains/')) {
442
+ summary += ' - Core domain logic changes from merge'
443
+ } else if (filePath.includes('src/infrastructure/')) {
444
+ summary += ' - Infrastructure and provider changes from merge'
445
+ } else if (filePath.includes('src/application/')) {
446
+ summary += ' - Application service changes from merge'
447
+ } else if (filePath.includes('bin/') || filePath.includes('cli')) {
448
+ summary += ' - CLI interface changes from merge'
449
+ } else if (totalChanges > 100) {
450
+ summary += ' - Major code changes from merge'
451
+ } else if (totalChanges > 20) {
452
+ summary += ' - Moderate code changes from merge'
453
+ } else {
454
+ summary += ' - Minor code changes from merge'
455
+ }
456
+
457
+ return summary
458
+ }
459
+
460
+ /**
461
+ * Generate comprehensive merge commit summary with categorized changes and technical details
462
+ */
463
+ generateMergeCommitSummary(files, commitHash, subject) {
464
+ const categories = {
465
+ tests: { count: 0, lines: 0, files: [] },
466
+ docs: { count: 0, lines: 0, files: [] },
467
+ config: { count: 0, lines: 0, files: [] },
468
+ core: { count: 0, lines: 0, files: [] },
469
+ infrastructure: { count: 0, lines: 0, files: [] },
470
+ cli: { count: 0, lines: 0, files: [] },
471
+ other: { count: 0, lines: 0, files: [] },
472
+ }
473
+
474
+ let totalLines = 0
475
+ let totalFiles = files.length
476
+
477
+ // Categorize files and accumulate statistics
478
+ for (const file of files) {
479
+ const changes = file.changeCount || 0
480
+ totalLines += changes
481
+
482
+ if (file.filePath.includes('test')) {
483
+ categories.tests.count++
484
+ categories.tests.lines += changes
485
+ categories.tests.files.push(file.filePath)
486
+ } else if (
487
+ file.filePath.includes('.md') ||
488
+ file.filePath.includes('README') ||
489
+ file.filePath.includes('docs/')
490
+ ) {
491
+ categories.docs.count++
492
+ categories.docs.lines += changes
493
+ categories.docs.files.push(file.filePath)
494
+ } else if (
495
+ file.filePath.includes('config') ||
496
+ file.filePath.includes('.json') ||
497
+ file.filePath.includes('package.json')
498
+ ) {
499
+ categories.config.count++
500
+ categories.config.lines += changes
501
+ categories.config.files.push(file.filePath)
502
+ } else if (file.filePath.includes('src/domains/')) {
503
+ categories.core.count++
504
+ categories.core.lines += changes
505
+ categories.core.files.push(file.filePath)
506
+ } else if (file.filePath.includes('src/infrastructure/')) {
507
+ categories.infrastructure.count++
508
+ categories.infrastructure.lines += changes
509
+ categories.infrastructure.files.push(file.filePath)
510
+ } else if (file.filePath.includes('bin/') || file.filePath.includes('cli')) {
511
+ categories.cli.count++
512
+ categories.cli.lines += changes
513
+ categories.cli.files.push(file.filePath)
514
+ } else {
515
+ categories.other.count++
516
+ categories.other.lines += changes
517
+ categories.other.files.push(file.filePath)
518
+ }
519
+ }
520
+
521
+ // Extract technical details from key files for more specific descriptions
522
+ const technicalDetails = this.extractTechnicalDetailsFromMerge(files, commitHash)
523
+
524
+ // Generate detailed summary bullets with specifics
525
+ const bullets = []
526
+
527
+ if (categories.tests.count > 0) {
528
+ const testSamples = categories.tests.files
529
+ .slice(0, 3)
530
+ .map((f) => f.split('/').pop())
531
+ .join(', ')
532
+ bullets.push(
533
+ `Added comprehensive test infrastructure with ${categories.tests.count} test files (${testSamples}${categories.tests.count > 3 ? ', ...' : ''}) totaling ${categories.tests.lines.toLocaleString()} lines of test code`
534
+ )
535
+ }
536
+
537
+ if (categories.core.count > 0) {
538
+ const coreSamples = categories.core.files
539
+ .slice(0, 3)
540
+ .map((f) => f.split('/').pop())
541
+ .join(', ')
542
+ const coreDetails = technicalDetails.filter(
543
+ (detail) => detail.includes('Enhanced') && detail.includes('.js')
544
+ )
545
+ const detailSuffix =
546
+ coreDetails.length > 0 ? ` with ${coreDetails.slice(0, 2).join(' and ')}` : ''
547
+ bullets.push(
548
+ `Enhanced core domain services (${coreSamples}${categories.core.count > 3 ? ', ...' : ''}) for changelog generation, AI analysis, and Git operations with ${categories.core.lines.toLocaleString()} lines changed${detailSuffix}`
549
+ )
550
+ }
551
+
552
+ if (categories.infrastructure.count > 0) {
553
+ const infraSamples = categories.infrastructure.files
554
+ .slice(0, 3)
555
+ .map((f) => f.split('/').pop())
556
+ .join(', ')
557
+ bullets.push(
558
+ `Updated provider integrations and infrastructure services (${infraSamples}${categories.infrastructure.count > 3 ? ', ...' : ''}) across ${categories.infrastructure.count} files`
559
+ )
560
+ }
561
+
562
+ if (categories.cli.count > 0) {
563
+ const cliSamples = categories.cli.files
564
+ .slice(0, 3)
565
+ .map((f) => f.split('/').pop())
566
+ .join(', ')
567
+ bullets.push(
568
+ `Improved CLI interface and command handling (${cliSamples}${categories.cli.count > 3 ? ', ...' : ''}) across ${categories.cli.count} files`
569
+ )
570
+ }
571
+
572
+ if (categories.docs.count > 0) {
573
+ const docSamples = categories.docs.files
574
+ .slice(0, 3)
575
+ .map((f) => f.split('/').pop())
576
+ .join(', ')
577
+ bullets.push(
578
+ `Updated documentation and guides (${docSamples}${categories.docs.count > 3 ? ', ...' : ''}) across ${categories.docs.count} files`
579
+ )
580
+ }
581
+
582
+ if (categories.config.count > 0) {
583
+ const configSamples = categories.config.files
584
+ .slice(0, 3)
585
+ .map((f) => f.split('/').pop())
586
+ .join(', ')
587
+ const configDetails = technicalDetails.filter(
588
+ (detail) =>
589
+ detail.includes('dependencies') ||
590
+ detail.includes('configuration') ||
591
+ detail.includes('.gitignore')
592
+ )
593
+ const detailSuffix =
594
+ configDetails.length > 0 ? ` including ${configDetails.slice(0, 2).join(' and ')}` : ''
595
+ bullets.push(
596
+ `Modified configuration files and dependencies (${configSamples}${categories.config.count > 3 ? ', ...' : ''}) across ${categories.config.count} files${detailSuffix}`
597
+ )
598
+ }
599
+
600
+ // Create enhanced merge commit description
601
+ let description = `${subject} brought together major updates across ${totalFiles} files with ${totalLines.toLocaleString()} total line changes:\n\n`
602
+
603
+ if (bullets.length > 0) {
604
+ description += bullets.map((bullet) => ` • ${bullet}`).join('\n')
605
+ } else {
606
+ description += ` • Major codebase changes across multiple modules and services`
607
+ }
608
+
609
+ return description
610
+ }
611
+
612
+ /**
613
+ * Extract technical details from key merge commit files for specific descriptions
614
+ */
615
+ extractTechnicalDetailsFromMerge(files, commitHash) {
616
+ const details = []
617
+
618
+ // Focus on key configuration and important files for technical details
619
+ const keyFiles = files
620
+ .filter((file) => {
621
+ const path = file.filePath.toLowerCase()
622
+ return (
623
+ path.includes('package.json') ||
624
+ path.includes('.config.') ||
625
+ path.includes('biome.json') ||
626
+ path.includes('.gitignore') ||
627
+ path.endsWith('.md') ||
628
+ (path.includes('src/') && file.changeCount > 100)
629
+ ) // Major code changes
630
+ })
631
+ .slice(0, 5) // Limit to 5 key files to avoid overwhelming
632
+
633
+ for (const file of keyFiles) {
634
+ try {
635
+ // Get a sample of the actual diff for technical details
636
+ const diffCommand = `git show ${commitHash} --pretty=format: -U2 -- "${file.filePath}"`
637
+ const diff = this.gitManager.execGitSafe(diffCommand)
638
+
639
+ if (diff && diff.length > 0) {
640
+ const techDetail = this.extractSpecificChanges(file.filePath, diff)
641
+ if (techDetail) {
642
+ details.push(techDetail)
643
+ }
644
+ }
645
+ } catch (_error) {}
646
+ }
647
+
648
+ return details
649
+ }
650
+
651
+ /**
652
+ * Extract specific technical changes from a diff
653
+ */
654
+ extractSpecificChanges(filePath, diff) {
655
+ const fileName = filePath.split('/').pop()
656
+
657
+ // Package.json changes
658
+ if (fileName === 'package.json') {
659
+ const versionChanges = diff.match(/[-+]\s*"([^"]+)":\s*"([^"]+)"/g)
660
+ if (versionChanges && versionChanges.length > 0) {
661
+ const changes = versionChanges
662
+ .slice(0, 3)
663
+ .map((change) => {
664
+ const match = change.match(/[-+]\s*"([^"]+)":\s*"([^"]+)"/)
665
+ if (match) {
666
+ const [, pkg, version] = match
667
+ return `${pkg} to ${version}`
668
+ }
669
+ return change
670
+ })
671
+ .join(', ')
672
+ return `Updated dependencies: ${changes}${versionChanges.length > 3 ? ', ...' : ''}`
673
+ }
674
+ }
675
+
676
+ // Configuration file changes
677
+ if (fileName.includes('.json') || fileName.includes('.config')) {
678
+ const addedLines = diff
679
+ .split('\n')
680
+ .filter((line) => line.startsWith('+'))
681
+ .slice(0, 3)
682
+ if (addedLines.length > 0) {
683
+ const configChanges = addedLines
684
+ .map((line) => line.replace(/^\+\s*/, '').trim())
685
+ .filter((line) => line.length > 0)
686
+ if (configChanges.length > 0) {
687
+ return `Modified ${fileName} configuration: ${configChanges.slice(0, 2).join(', ')}${configChanges.length > 2 ? ', ...' : ''}`
688
+ }
689
+ }
690
+ }
691
+
692
+ // .gitignore changes
693
+ if (fileName === '.gitignore') {
694
+ const removedPatterns = diff
695
+ .split('\n')
696
+ .filter((line) => line.startsWith('-'))
697
+ .map((line) => line.replace(/^-\s*/, '').trim())
698
+ .filter((line) => line.length > 0)
699
+ const addedPatterns = diff
700
+ .split('\n')
701
+ .filter((line) => line.startsWith('+'))
702
+ .map((line) => line.replace(/^\+\s*/, '').trim())
703
+ .filter((line) => line.length > 0)
704
+
705
+ if (removedPatterns.length > 0 || addedPatterns.length > 0) {
706
+ let change = ''
707
+ if (removedPatterns.length > 0) {
708
+ change += `removed ${removedPatterns.slice(0, 3).join(', ')} patterns`
709
+ }
710
+ if (addedPatterns.length > 0) {
711
+ change += `${change ? ' and ' : ''}added ${addedPatterns.slice(0, 3).join(', ')} patterns`
712
+ }
713
+ return `Updated .gitignore: ${change}`
714
+ }
715
+ }
716
+
717
+ // Major code files - look for function/method additions
718
+ if (filePath.includes('src/') && fileName.endsWith('.js')) {
719
+ const functionMatches = diff.match(/\+.*(?:function|async|const|let|var)\s+(\w+)/g)
720
+ if (functionMatches && functionMatches.length > 0) {
721
+ const functions = functionMatches
722
+ .slice(0, 3)
723
+ .map((match) => {
724
+ const funcMatch = match.match(/\+.*(?:function|async|const|let|var)\s+(\w+)/)
725
+ return funcMatch ? funcMatch[1] : null
726
+ })
727
+ .filter(Boolean)
728
+
729
+ if (functions.length > 0) {
730
+ return `Enhanced ${fileName}: added ${functions.join(', ')}${functionMatches.length > 3 ? ', ...' : ''} functions`
731
+ }
732
+ }
733
+ }
734
+
735
+ return null
736
+ }
737
+
738
+ // Repository analysis methods (formerly in GitRepositoryAnalyzer)
739
+ async assessRepositoryHealth(config = {}) {
740
+ try {
741
+ const health = {
742
+ healthy: true,
743
+ score: 100,
744
+ issues: [],
745
+ recommendations: [],
746
+ metrics: {
747
+ branches: 0,
748
+ commits: 0,
749
+ untrackedFiles: 0,
750
+ staleBranches: 0,
751
+ largeBinaryFiles: 0,
752
+ commitFrequency: 0,
753
+ },
754
+ }
755
+
756
+ // Get basic repository statistics
757
+ try {
758
+ const branchesOutput = this.gitManager.execGitSafe('git branch -a')
759
+ health.metrics.branches = branchesOutput.split('\n').filter((line) => line.trim()).length
760
+
761
+ const commitsOutput = this.gitManager.execGitSafe('git rev-list --all --count')
762
+ health.metrics.commits = parseInt(commitsOutput.trim(), 10) || 0
763
+
764
+ const untrackedOutput = this.gitManager.execGitSafe(
765
+ 'git ls-files --others --exclude-standard'
766
+ )
767
+ health.metrics.untrackedFiles = untrackedOutput
768
+ .split('\n')
769
+ .filter((line) => line.trim()).length
770
+ } catch (error) {
771
+ health.issues.push(`Failed to collect basic metrics: ${error.message}`)
772
+ health.score -= 10
773
+ }
774
+
775
+ // Check for stale branches (no commits in last 90 days)
776
+ try {
777
+ const staleBranchesOutput = this.gitManager.execGitSafe(
778
+ 'git for-each-ref --format="%(refname:short) %(committerdate:iso)" refs/heads'
779
+ )
780
+ const staleThreshold = new Date()
781
+ staleThreshold.setDate(staleThreshold.getDate() - 90)
782
+
783
+ health.metrics.staleBranches = staleBranchesOutput.split('\n').filter((line) => {
784
+ const parts = line.trim().split(' ')
785
+ if (parts.length < 2) return false
786
+ const commitDate = new Date(parts[1])
787
+ return commitDate < staleThreshold
788
+ }).length
789
+
790
+ if (health.metrics.staleBranches > 5) {
791
+ health.issues.push(
792
+ `${health.metrics.staleBranches} stale branches found (no commits in 90+ days)`
793
+ )
794
+ health.recommendations.push(
795
+ 'Consider cleaning up old branches with: git branch -d <branch-name>'
796
+ )
797
+ health.score -= Math.min(20, health.metrics.staleBranches * 2)
798
+ }
799
+ } catch (error) {
800
+ console.warn(`Warning: Could not check for stale branches: ${error.message}`)
801
+ }
802
+
803
+ // Check for large binary files in repository
804
+ try {
805
+ const largeFilesOutput = this.gitManager.execGitSafe(
806
+ 'git rev-list --objects --all | git cat-file --batch-check="%(objecttype) %(objectsize) %(rest)" | grep "^blob" | sort -nr -k2 | head -10'
807
+ )
808
+ const largeFiles = largeFilesOutput
809
+ .split('\n')
810
+ .filter((line) => line.trim())
811
+ .map((line) => {
812
+ const parts = line.split(' ')
813
+ return { size: parseInt(parts[1], 10), path: parts.slice(2).join(' ') }
814
+ })
815
+ .filter((file) => file.size > 10 * 1024 * 1024) // 10MB threshold
816
+
817
+ health.metrics.largeBinaryFiles = largeFiles.length
818
+ if (largeFiles.length > 0) {
819
+ health.issues.push(`${largeFiles.length} large files found (>10MB)`)
820
+ health.recommendations.push('Consider using Git LFS for large binary files')
821
+ health.score -= Math.min(15, largeFiles.length * 5)
822
+ }
823
+ } catch (error) {
824
+ console.warn(`Warning: Could not check for large files: ${error.message}`)
825
+ }
826
+
827
+ // Calculate commit frequency (commits per week over last month)
828
+ try {
829
+ const oneMonthAgo = new Date()
830
+ oneMonthAgo.setDate(oneMonthAgo.getDate() - 30)
831
+ const recentCommitsOutput = this.gitManager.execGitSafe(
832
+ `git rev-list --count --since="${oneMonthAgo.toISOString()}" HEAD`
833
+ )
834
+ const recentCommits = parseInt(recentCommitsOutput.trim(), 10) || 0
835
+ health.metrics.commitFrequency = Math.round((recentCommits / 4) * 10) / 10 // commits per week
836
+
837
+ if (health.metrics.commitFrequency < 1) {
838
+ health.issues.push('Low commit frequency (less than 1 commit per week)')
839
+ health.recommendations.push('Consider more frequent commits for better project tracking')
840
+ health.score -= 5
841
+ }
842
+ } catch (error) {
843
+ console.warn(`Warning: Could not calculate commit frequency: ${error.message}`)
844
+ }
845
+
846
+ // Check if working directory is clean
847
+ try {
848
+ const statusOutput = this.gitManager.execGitSafe('git status --porcelain')
849
+ if (statusOutput.trim()) {
850
+ health.issues.push('Working directory has uncommitted changes')
851
+ health.recommendations.push('Commit or stash working directory changes')
852
+ health.score -= 5
853
+ }
854
+ } catch (error) {
855
+ health.issues.push(`Could not check working directory status: ${error.message}`)
856
+ health.score -= 5
857
+ }
858
+
859
+ // Check for .gitignore file
860
+ try {
861
+ const gitignoreExists = this.gitManager.execGitSafe('test -f .gitignore && echo "exists"')
862
+ if (!gitignoreExists.includes('exists')) {
863
+ health.issues.push('No .gitignore file found')
864
+ health.recommendations.push('Add a .gitignore file to exclude unwanted files')
865
+ health.score -= 10
866
+ }
867
+ } catch (error) {
868
+ console.warn(`Warning: Could not check .gitignore: ${error.message}`)
869
+ }
870
+
871
+ // Determine overall health
872
+ health.healthy = health.score >= 70
873
+ health.score = Math.max(0, health.score)
874
+
875
+ return health
876
+ } catch (error) {
877
+ console.error(`Repository health assessment failed: ${error.message}`)
878
+ return {
879
+ healthy: false,
880
+ score: 0,
881
+ issues: [`Health assessment failed: ${error.message}`],
882
+ recommendations: ['Ensure you are in a valid Git repository'],
883
+ metrics: { branches: 0, commits: 0, untrackedFiles: 0 },
884
+ }
885
+ }
886
+ }
887
+
888
+ async analyzeBranches(format = 'markdown') {
889
+ try {
890
+ const analysis = {
891
+ branches: [],
892
+ unmergedCommits: [],
893
+ danglingCommits: [],
894
+ analysis: '',
895
+ }
896
+
897
+ // Get all branches with their last commit info
898
+ const branchesOutput = this.gitManager.execGitSafe(
899
+ 'git for-each-ref --format="%(refname:short)|%(committerdate:iso)|%(authorname)|%(subject)" refs/heads refs/remotes'
900
+ )
901
+
902
+ analysis.branches = branchesOutput
903
+ .split('\n')
904
+ .filter((line) => line.trim())
905
+ .map((line) => {
906
+ const [name, date, author, subject] = line.split('|')
907
+ const isRemote = name.startsWith('origin/')
908
+ const isStale = new Date() - new Date(date) > 90 * 24 * 60 * 60 * 1000 // 90 days
909
+
910
+ return {
911
+ name: name.trim(),
912
+ lastCommitDate: date,
913
+ lastCommitAuthor: author,
914
+ lastCommitSubject: subject,
915
+ isRemote,
916
+ isStale,
917
+ type: isRemote ? 'remote' : 'local',
918
+ }
919
+ })
920
+
921
+ // Find unmerged commits (commits in feature branches not in main/master)
922
+ try {
923
+ const mainBranch = this.findMainBranch()
924
+ if (mainBranch) {
925
+ const localBranches = analysis.branches.filter(
926
+ (b) => !b.isRemote && b.name !== mainBranch
927
+ )
928
+
929
+ for (const branch of localBranches) {
930
+ try {
931
+ const unmergedOutput = this.gitManager.execGitSafe(
932
+ `git log ${mainBranch}..${branch.name} --oneline`
933
+ )
934
+ const unmergedCommits = unmergedOutput
935
+ .split('\n')
936
+ .filter((line) => line.trim())
937
+ .map((line) => {
938
+ const [hash, ...messageParts] = line.split(' ')
939
+ return {
940
+ hash: hash.trim(),
941
+ message: messageParts.join(' '),
942
+ branch: branch.name,
943
+ }
944
+ })
945
+
946
+ analysis.unmergedCommits.push(...unmergedCommits)
947
+ } catch (error) {
948
+ console.warn(`Could not check unmerged commits for ${branch.name}: ${error.message}`)
949
+ }
950
+ }
951
+ }
952
+ } catch (error) {
953
+ console.warn(`Could not analyze unmerged commits: ${error.message}`)
954
+ }
955
+
956
+ // Find dangling commits (unreachable commits)
957
+ try {
958
+ const danglingOutput = this.gitManager.execGitSafe('git fsck --unreachable --no-reflogs')
959
+ analysis.danglingCommits = danglingOutput
960
+ .split('\n')
961
+ .filter((line) => line.includes('unreachable commit'))
962
+ .map((line) => {
963
+ const hash = line.split(' ').pop()
964
+ return { hash, type: 'dangling' }
965
+ })
966
+ } catch (error) {
967
+ console.warn(`Could not check for dangling commits: ${error.message}`)
968
+ }
969
+
970
+ // Generate analysis summary
971
+ const totalBranches = analysis.branches.length
972
+ const localBranches = analysis.branches.filter((b) => !b.isRemote).length
973
+ const staleBranches = analysis.branches.filter((b) => b.isStale).length
974
+ const unmergedCount = analysis.unmergedCommits.length
975
+ const danglingCount = analysis.danglingCommits.length
976
+
977
+ if (format === 'markdown') {
978
+ analysis.analysis = `# Branch Analysis
979
+
980
+ ## Summary
981
+ - **Total branches**: ${totalBranches} (${localBranches} local, ${totalBranches - localBranches} remote)
982
+ - **Stale branches**: ${staleBranches} (no commits in 90+ days)
983
+ - **Unmerged commits**: ${unmergedCount}
984
+ - **Dangling commits**: ${danglingCount}
985
+
986
+ ## Branch Details
987
+ ${analysis.branches
988
+ .map(
989
+ (b) =>
990
+ `- **${b.name}** ${b.isStale ? '(stale)' : ''}\n - Last commit: ${b.lastCommitDate} by ${b.lastCommitAuthor}\n - Subject: ${b.lastCommitSubject}`
991
+ )
992
+ .join('\n')}
993
+
994
+ ${
995
+ unmergedCount > 0
996
+ ? `\n## Unmerged Commits\n${analysis.unmergedCommits
997
+ .slice(0, 10)
998
+ .map((c) => `- ${c.hash}: ${c.message} (${c.branch})`)
999
+ .join('\n')}${unmergedCount > 10 ? `\n... and ${unmergedCount - 10} more` : ''}`
1000
+ : ''
1001
+ }
1002
+
1003
+ ${
1004
+ danglingCount > 0
1005
+ ? `\n## Dangling Commits\n${analysis.danglingCommits
1006
+ .slice(0, 5)
1007
+ .map((c) => `- ${c.hash}`)
1008
+ .join('\n')}${danglingCount > 5 ? `\n... and ${danglingCount - 5} more` : ''}`
1009
+ : ''
1010
+ }
1011
+ `
1012
+ } else {
1013
+ analysis.analysis = `Found ${totalBranches} branches (${staleBranches} stale), ${unmergedCount} unmerged commits, ${danglingCount} dangling commits`
1014
+ }
1015
+
1016
+ return analysis
1017
+ } catch (error) {
1018
+ console.error(`Branch analysis failed: ${error.message}`)
1019
+ return {
1020
+ branches: [],
1021
+ unmergedCommits: [],
1022
+ danglingCommits: [],
1023
+ analysis: `Branch analysis failed: ${error.message}`,
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ async analyzeComprehensive(includeRecommendations = true) {
1029
+ try {
1030
+ console.log('🔍 Performing comprehensive repository analysis...')
1031
+
1032
+ const analysis = {
1033
+ analysis: '',
1034
+ recommendations: includeRecommendations ? [] : undefined,
1035
+ metrics: {},
1036
+ health: {},
1037
+ }
1038
+
1039
+ // Get repository health
1040
+ analysis.health = await this.assessRepositoryHealth()
1041
+
1042
+ // Get branch analysis
1043
+ const branchAnalysis = await this.analyzeBranches('object')
1044
+
1045
+ // Get repository statistics
1046
+ analysis.metrics = {
1047
+ ...analysis.health.metrics,
1048
+ totalCommits: analysis.health.metrics.commits,
1049
+ totalBranches: branchAnalysis.branches.length,
1050
+ unmergedCommits: branchAnalysis.unmergedCommits.length,
1051
+ danglingCommits: branchAnalysis.danglingCommits.length,
1052
+ }
1053
+
1054
+ // Analyze commit patterns
1055
+ try {
1056
+ const commitHistory = this.gitManager.execGitSafe('git log --oneline --since="30 days ago"')
1057
+ const recentCommits = commitHistory.split('\n').filter((line) => line.trim())
1058
+
1059
+ const conventionalCommits = recentCommits.filter((commit) =>
1060
+ /^[a-f0-9]+\s+(feat|fix|docs|style|refactor|test|chore|perf|build|ci)(\(.+\))?:/.test(
1061
+ commit
1062
+ )
1063
+ )
1064
+
1065
+ analysis.metrics.conventionalCommitRatio =
1066
+ recentCommits.length > 0
1067
+ ? Math.round((conventionalCommits.length / recentCommits.length) * 100)
1068
+ : 0
1069
+
1070
+ analysis.metrics.recentCommitsCount = recentCommits.length
1071
+ } catch (error) {
1072
+ console.warn(`Could not analyze commit patterns: ${error.message}`)
1073
+ analysis.metrics.conventionalCommitRatio = 0
1074
+ analysis.metrics.recentCommitsCount = 0
1075
+ }
1076
+
1077
+ // Calculate repository age
1078
+ try {
1079
+ const firstCommitOutput = this.gitManager.execGitSafe(
1080
+ 'git log --reverse --format="%ci" | head -1'
1081
+ )
1082
+ if (firstCommitOutput.trim()) {
1083
+ const firstCommitDate = new Date(firstCommitOutput.trim())
1084
+ const ageInDays = Math.floor((new Date() - firstCommitDate) / (1000 * 60 * 60 * 24))
1085
+ analysis.metrics.repositoryAgeInDays = ageInDays
1086
+ }
1087
+ } catch (error) {
1088
+ console.warn(`Could not determine repository age: ${error.message}`)
1089
+ }
1090
+
1091
+ // Generate comprehensive analysis
1092
+ const healthScore = analysis.health.score
1093
+ const isHealthy = analysis.health.healthy
1094
+ const staleBranches = branchAnalysis.branches.filter((b) => b.isStale).length
1095
+ const conventionalRatio = analysis.metrics.conventionalCommitRatio
1096
+
1097
+ analysis.analysis = `# Comprehensive Repository Analysis
1098
+
1099
+ ## Overall Health: ${healthScore}/100 ${isHealthy ? '✅' : '⚠️'}
1100
+
1101
+ ### Repository Metrics
1102
+ - **Age**: ${analysis.metrics.repositoryAgeInDays || 'Unknown'} days
1103
+ - **Total Commits**: ${analysis.metrics.totalCommits}
1104
+ - **Branches**: ${analysis.metrics.totalBranches} (${staleBranches} stale)
1105
+ - **Recent Activity**: ${analysis.metrics.recentCommitsCount} commits in last 30 days
1106
+ - **Commit Convention**: ${conventionalRatio}% following conventional commits
1107
+
1108
+ ### Issues Found
1109
+ ${analysis.health.issues.length > 0 ? analysis.health.issues.map((issue) => `- ${issue}`).join('\n') : '- No major issues detected'}
1110
+
1111
+ ### Branch Health
1112
+ - **Unmerged commits**: ${analysis.metrics.unmergedCommits}
1113
+ - **Dangling commits**: ${analysis.metrics.danglingCommits}
1114
+ - **Stale branches**: ${staleBranches}
1115
+ `
1116
+
1117
+ // Add recommendations if requested
1118
+ if (includeRecommendations && analysis.health.recommendations.length > 0) {
1119
+ analysis.recommendations = [
1120
+ ...analysis.health.recommendations,
1121
+ ...(staleBranches > 3
1122
+ ? ['Clean up stale branches to improve repository organization']
1123
+ : []),
1124
+ ...(conventionalRatio < 50
1125
+ ? ['Consider adopting conventional commit format for better changelog generation']
1126
+ : []),
1127
+ ...(analysis.metrics.unmergedCommits > 10
1128
+ ? ['Review and merge or clean up unmerged commits']
1129
+ : []),
1130
+ ]
1131
+ }
1132
+
1133
+ return analysis
1134
+ } catch (error) {
1135
+ console.error(`Comprehensive analysis failed: ${error.message}`)
1136
+ return {
1137
+ analysis: `Comprehensive analysis failed: ${error.message}`,
1138
+ recommendations: includeRecommendations
1139
+ ? ['Ensure you are in a valid Git repository']
1140
+ : undefined,
1141
+ metrics: {},
1142
+ health: { healthy: false, score: 0 },
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ // Helper method to find the main branch (master/main)
1148
+ findMainBranch() {
1149
+ try {
1150
+ const branches = this.gitManager.execGitSafe('git branch -l')
1151
+ const branchNames = branches
1152
+ .split('\n')
1153
+ .map((b) => b.replace('*', '').trim())
1154
+ .filter(Boolean)
1155
+
1156
+ // Check for common main branch names in order of preference
1157
+ const mainBranchCandidates = ['main', 'master', 'develop', 'dev']
1158
+ for (const candidate of mainBranchCandidates) {
1159
+ if (branchNames.includes(candidate)) {
1160
+ return candidate
1161
+ }
1162
+ }
1163
+
1164
+ // Fallback to first branch if no standard main branch found
1165
+ return branchNames[0] || 'main'
1166
+ } catch (error) {
1167
+ console.warn(`Could not determine main branch: ${error.message}`)
1168
+ return 'main'
1169
+ }
1170
+ }
322
1171
  }