@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
1,042 lines • 60.8 kB
JavaScript
import colors from '../../shared/constants/colors.js';
import { GitError } from '../../shared/utils/error-classes.js';
import { analyzeFunctionalImpact, analyzeSemanticChanges, assessChangeComplexity, assessFileImportance, categorizeFile, detectLanguage, } from '../../shared/utils/utils.js';
function isValidGitDateInput(value) {
if (!value.trim()) {
return false;
}
const normalizedValue = value.trim().toLowerCase();
const relativeDatePattern = /^(?:\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago|yesterday|today|now)$/i;
return !Number.isNaN(Date.parse(value)) || relativeDatePattern.test(normalizedValue);
}
function createEmptyRepositoryMetrics() {
return {
branches: 0,
commits: 0,
untrackedFiles: 0,
staleBranches: 0,
largeBinaryFiles: 0,
commitFrequency: 0,
};
}
export class GitService {
constructor(gitManager, tagger) {
this.gitManager = gitManager;
this.tagger = tagger;
}
async getCommitAnalysis(commitHash) {
try {
// Validate commit hash first
if (!this.gitManager.validateCommitHash(commitHash)) {
console.warn(colors.warningMessage(`Invalid commit hash: ${colors.hash(commitHash)}`));
return null;
}
// Get comprehensive commit information
const commitInfo = this.gitManager.execGit(`git show --pretty=format:"%H|%s|%an|%ad|%B" --no-patch ${commitHash}`);
const lines = commitInfo.split('\n');
const [hash, subject, author, date] = lines[0].split('|');
const body = lines.slice(1).join('\n').trim();
// Detect merge commits and handle them differently
let isMergeCommit = subject.toLowerCase().includes('merge');
if (!isMergeCommit) {
// Check if commit has multiple parents (more reliable for merge detection)
try {
const parents = this.gitManager
.execGitSafe(`git show --format='%P' --no-patch ${commitHash}`)
.trim()
.split(' ');
isMergeCommit = parents.length > 1;
}
catch (error) {
console.warn(colors.warningMessage(`Failed to check parent commits: ${error.message}`));
isMergeCommit = false;
}
}
let files = [];
if (isMergeCommit) {
// For merge commits, get the stat summary and create a special analysis
const statOutput = this.gitManager.execGitSafe(`git show --stat --pretty=format: ${commitHash}`);
files = this.processMergeCommitStats(commitHash, statOutput);
// Generate comprehensive summary for large merge commits
if (files.length > 10) {
const enhancedSummary = this.generateMergeCommitSummary(files, commitHash, subject);
// Add the enhanced summary to the first file entry for the AI to use
if (files.length > 0) {
files[0].enhancedMergeSummary = enhancedSummary;
}
}
}
else {
// For regular commits, use the existing approach
const filesCommand = `git show --name-status --pretty=format: ${commitHash}`;
const filesOutput = this.gitManager.execGitSafe(filesCommand);
files = await Promise.all(filesOutput
.split('\n')
.filter(Boolean)
.map(async (line) => {
const parts = line.split('\t');
if (parts.length < 2) {
return null;
}
const [status, filePath] = parts;
return await this.analyzeFileChange(commitHash, status, filePath);
}));
}
// Filter out null entries
const validFiles = files.filter(Boolean);
// Get overall diff statistics
const diffStats = this.getCommitDiffStats(commitHash);
// Use intelligent tagging system
const commitForTagging = {
hash: hash.substring(0, 7),
message: subject,
files: validFiles.map((f) => ({ path: f.filePath })),
stats: diffStats,
};
const taggingAnalysis = this.tagger.analyzeCommit(commitForTagging);
const analysis = {
hash: hash.substring(0, 7),
fullHash: hash,
subject,
author,
date,
body,
files: validFiles,
diffStats,
semanticChanges: taggingAnalysis.semanticChanges || [],
breakingChanges: taggingAnalysis.breakingChanges || [],
categories: taggingAnalysis.categories || [],
importance: taggingAnalysis.importance || 'medium',
tags: taggingAnalysis.tags || [],
};
return analysis;
}
catch (error) {
const gitError = GitError.fromCommandFailure('show', null, null, error.message, error);
console.error(colors.errorMessage(`Error analyzing commit ${commitHash}:`), gitError.message);
return null;
}
}
async analyzeFileChange(commitHash, status, filePath) {
try {
// Get file diff with context
const diffCommand = `git show ${commitHash} --pretty=format: -U5 -- "${filePath}"`;
let diff = '';
diff = this.gitManager.execGitShow(diffCommand);
if (diff === null) {
if (status === 'D') {
diff = 'File deleted in this commit';
}
else {
console.warn(colors.warningMessage(`⚠️ File ${colors.file(filePath)} diff failed for commit ${commitHash}`));
diff = 'File content unavailable (git show failed)';
}
}
else if (!diff || diff.trim() === '') {
if (status === 'A') {
// For new files, try to get the content directly
const newFileContent = this.gitManager.execGitShow(`git show ${commitHash}:"${filePath}"`);
if (newFileContent && newFileContent.length > 0) {
diff = `New file created with content:\n${newFileContent.slice(0, 1000)}${newFileContent.length > 1000 ? '\n...' : ''}`;
}
else {
diff = 'New file created (content unavailable)';
}
}
else {
diff = 'No changes detected (binary or empty file)';
}
}
// Get file content context
let beforeContent = '';
let afterContent = '';
if (status !== 'A' && !diff.includes('not available')) {
const beforeResult = this.gitManager.execGitShow(`git show ${commitHash}~1:"${filePath}"`);
beforeContent = beforeResult ? beforeResult.slice(0, 1000) : '';
}
if (status !== 'D' && !diff.includes('not available')) {
const afterResult = this.gitManager.execGitShow(`git show ${commitHash}:"${filePath}"`);
afterContent = afterResult ? afterResult.slice(0, 1000) : '';
}
return {
status,
filePath,
diff,
beforeContent,
afterContent,
category: categorizeFile(filePath),
language: detectLanguage(filePath),
importance: assessFileImportance(filePath, status),
complexity: assessChangeComplexity(diff),
semanticChanges: analyzeSemanticChanges(diff, filePath),
functionalImpact: analyzeFunctionalImpact(diff, filePath, status),
};
}
catch (error) {
console.error(colors.errorMessage(`Error analyzing file change ${filePath}:`), error.message);
return null;
}
}
async analyzeWorkingDirectoryFileChange(status, filePath) {
try {
let diff = '';
// Get working directory diff based on status
if (status === 'A' || status === '??') {
// New/untracked file - show entire content (first 50 lines)
try {
const content = this.gitManager.execGitSafe(`cat "${filePath}"`);
if (content?.trim()) {
const lines = content.split('\n').slice(0, 50);
diff = `New file created with ${content.split('\n').length} lines\n\nContent preview:\n${lines.join('\n')}${content.split('\n').length > 50 ? '\n... (truncated)' : ''}`;
}
else {
diff = 'New empty file created';
}
}
catch (error) {
console.warn(colors.warningMessage(`Failed to read new file content: ${error.message}`));
diff = 'New file created (binary or inaccessible)';
}
}
else if (status === 'D') {
// Deleted file - get the content that was removed for better analysis
try {
const headContent = this.gitManager.execGitSafe(`git show HEAD:"${filePath}"`);
if (headContent?.trim()) {
// Get first 30 lines to understand what was removed
const lines = headContent.split('\n').slice(0, 30);
diff = `File deleted from working directory\n\nRemoved content preview:\n${lines.join('\n')}${headContent.split('\n').length > 30 ? '\n... (truncated)' : ''}`;
}
else {
diff = 'File deleted from working directory (content was empty)';
}
}
catch (error) {
console.warn(colors.warningMessage(`Failed to read deleted file content: ${error.message}`));
diff = 'File deleted from working directory (content unavailable)';
}
}
else if (status === 'M' || status.includes('M')) {
// Modified file - get actual diff
try {
const diffCommand = `git diff HEAD -- "${filePath}"`;
diff = this.gitManager.execGitSafe(diffCommand);
if (!diff || diff.trim() === '') {
// Try staged diff if no working directory diff
const stagedDiff = this.gitManager.execGitSafe(`git diff --cached -- "${filePath}"`);
diff = stagedDiff || 'No diff available (binary or identical)';
}
}
catch (error) {
console.warn(colors.warningMessage(`Failed to generate diff: ${error.message}`));
diff = 'Modified file (diff unavailable)';
}
}
else if (status === 'R') {
// Renamed file
diff = 'File renamed in working directory';
}
else {
// Other status
diff = `File status: ${status}`;
}
// Get file content context
let beforeContent = '';
let afterContent = '';
if (status === 'D') {
// For deleted files, get the content that was removed
try {
const headResult = this.gitManager.execGitSafe(`git show HEAD:"${filePath}"`);
beforeContent = headResult ? headResult.slice(0, 1000) : '';
}
catch (error) {
console.warn(colors.warningMessage(`Failed to get before content: ${error.message}`));
beforeContent = '';
}
// afterContent stays empty for deleted files
}
else if (status !== 'A' && status !== '??') {
// For modified files, get both before and after
try {
// Get HEAD version
const headResult = this.gitManager.execGitSafe(`git show HEAD:"${filePath}"`);
beforeContent = headResult ? headResult.slice(0, 1000) : '';
}
catch (error) {
console.warn(colors.warningMessage(`Failed to get before content (modified): ${error.message}`));
beforeContent = '';
}
try {
// Get current working directory version
const currentResult = this.gitManager.execGitSafe(`cat "${filePath}"`);
afterContent = currentResult ? currentResult.slice(0, 1000) : '';
}
catch (error) {
console.warn(colors.warningMessage(`Failed to get after content: ${error.message}`));
afterContent = '';
}
}
return {
status,
filePath,
diff,
beforeContent,
afterContent,
category: categorizeFile(filePath),
language: detectLanguage(filePath),
importance: assessFileImportance(filePath, status),
complexity: assessChangeComplexity(diff),
semanticChanges: analyzeSemanticChanges(diff, filePath),
functionalImpact: analyzeFunctionalImpact(diff, filePath, status),
};
}
catch (error) {
console.error(colors.errorMessage(`Error analyzing working directory file change ${filePath}:`), error.message);
return {
status,
filePath,
diff: 'Analysis failed',
beforeContent: '',
afterContent: '',
category: categorizeFile(filePath),
language: 'unknown',
importance: 'medium',
complexity: { score: 1 },
semanticChanges: {
changeType: 'unknown',
patterns: [],
frameworks: [],
},
functionalImpact: { scope: 'local', severity: 'low' },
};
}
}
async analyzeStagedFileChange(status, filePath) {
const normalizedStatus = String(status || 'M')
.trim()
.charAt(0) || 'M';
const escapedFilePath = String(filePath).replace(/(["\\$`])/g, '\\$1');
try {
let diff = '';
try {
diff = this.gitManager.execGitSafe(`git diff --cached -- "${escapedFilePath}"`);
}
catch {
diff = '';
}
if ((normalizedStatus === 'A' || normalizedStatus === 'C') && (!diff || diff.trim() === '')) {
try {
const stagedContent = this.gitManager.execGitShow(`git show ":${escapedFilePath}"`);
if (stagedContent?.trim()) {
const lines = stagedContent.split('\n');
diff = `New staged file with ${lines.length} lines\n\nContent preview:\n${lines.slice(0, 50).join('\n')}${lines.length > 50 ? '\n... (truncated)' : ''}`;
}
else {
diff = 'New staged file created';
}
}
catch {
diff = 'New staged file created (binary or inaccessible)';
}
}
else if (normalizedStatus === 'D' && (!diff || diff.trim() === '')) {
try {
const headContent = this.gitManager.execGitShow(`git show HEAD:"${escapedFilePath}"`);
if (headContent?.trim()) {
const lines = headContent.split('\n');
diff = `File staged for deletion\n\nRemoved content preview:\n${lines.slice(0, 30).join('\n')}${lines.length > 30 ? '\n... (truncated)' : ''}`;
}
else {
diff = 'File staged for deletion';
}
}
catch {
diff = 'File staged for deletion (content unavailable)';
}
}
else if (!diff || diff.trim() === '') {
diff = `File staged with status: ${normalizedStatus}`;
}
const numStat = this.gitManager.execGitSafe(`git diff --cached --numstat -- "${escapedFilePath}"`);
const [additionsRaw = '0', deletionsRaw = '0'] = (numStat.trim().split('\t') || []).slice(0, 2);
const additions = additionsRaw === '-' ? 0 : Number.parseInt(additionsRaw || '0', 10) || 0;
const deletions = deletionsRaw === '-' ? 0 : Number.parseInt(deletionsRaw || '0', 10) || 0;
let beforeContent = '';
let afterContent = '';
if (normalizedStatus !== 'A' && normalizedStatus !== 'C') {
beforeContent =
this.gitManager.execGitShow(`git show HEAD:"${escapedFilePath}"`)?.slice(0, 1000) || '';
}
if (normalizedStatus !== 'D') {
afterContent =
this.gitManager.execGitShow(`git show ":${escapedFilePath}"`)?.slice(0, 1000) || '';
}
return {
status: normalizedStatus,
filePath,
diff,
beforeContent,
afterContent,
additions,
deletions,
category: categorizeFile(filePath),
language: detectLanguage(filePath),
importance: assessFileImportance(filePath, normalizedStatus),
complexity: assessChangeComplexity(diff),
semanticChanges: analyzeSemanticChanges(diff, filePath),
functionalImpact: analyzeFunctionalImpact(diff, filePath, normalizedStatus),
};
}
catch (error) {
console.error(colors.errorMessage(`Error analyzing staged file change ${filePath}:`), error.message);
return {
status: normalizedStatus,
filePath,
diff: 'Analysis failed',
beforeContent: '',
afterContent: '',
additions: 0,
deletions: 0,
category: categorizeFile(filePath),
language: 'unknown',
importance: 'medium',
complexity: { score: 1 },
semanticChanges: {
changeType: 'unknown',
patterns: [],
frameworks: [],
},
functionalImpact: { scope: 'local', severity: 'low' },
};
}
}
getCommitDiffStats(commitHash) {
try {
const command = `git show --stat --pretty=format: ${commitHash}`;
const output = this.gitManager.execGitSafe(command);
const lines = output.split('\n').filter(Boolean);
const summary = lines.at(-1);
if (summary?.includes('changed')) {
const match = summary.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
if (match) {
return {
files: Number.parseInt(match[1], 10),
insertions: Number.parseInt(match[2] || 0, 10),
deletions: Number.parseInt(match[3] || 0, 10),
};
}
}
return { files: 0, insertions: 0, deletions: 0 };
}
catch (error) {
console.warn(colors.warningMessage(`Failed to get diff stats: ${error.message}`));
return { files: 0, insertions: 0, deletions: 0 };
}
}
async getCommitsSince(since, options = {}) {
if (since) {
const normalizedSince = String(since).trim();
const isRef = /^v?\d|^[a-f0-9]{6,40}$/i.test(normalizedSince);
if (!isRef && !isValidGitDateInput(normalizedSince)) {
throw new Error(`Invalid "since" value: ${normalizedSince}. Use a commit/tag reference or a valid date.`);
}
}
try {
let command = 'git log --oneline';
// Handle different "since" formats
if (since) {
const normalizedSince = String(since).trim();
const isRef = /^v?\d|^[a-f0-9]{6,40}$/i.test(normalizedSince);
if (isRef) {
const until = options.until || 'HEAD';
command = `git log ${normalizedSince}..${until} --oneline`;
}
else {
command += ` --since="${normalizedSince}"`;
// Apply an upper bound only when it is a real date. A symbolic ref such as
// HEAD (the default upper bound) or a tag is incoherent with a date-based
// --since query — "now" is already the top — so it is intentionally ignored
// here; explicit ref/tag ranges are handled by getCommitsBetween().
if (options.until && isValidGitDateInput(String(options.until).trim())) {
command += ` --until="${String(options.until).trim()}"`;
}
}
}
else {
command += ' -10';
}
// Add author filter if specified
if (options.author) {
command += ` --author="${options.author}"`;
}
const output = this.gitManager.execGitSafe(command);
return output
.split('\n')
.filter(Boolean)
.map((line) => {
const [hash, ...messageParts] = line.split(' ');
return {
hash: hash.substring(0, 7),
message: messageParts.join(' '),
};
});
}
catch (error) {
const message = error?.message || String(error);
// A failure here for a user-supplied `since` almost always means the ref/range does
// not exist: an empty range exits 0 with no output and does NOT throw. Surface it
// instead of returning [], which would masquerade a typo'd tag/SHA as "no changes".
if (since && /unknown revision|ambiguous argument|bad revision|fatal:/i.test(message)) {
throw new Error(`Unknown git ref for "since": "${String(since).trim()}". Use an existing commit, tag, or a valid date.`, { cause: error });
}
console.error(colors.errorMessage('Error getting commits since:'), message);
return [];
}
}
/**
* Get commits in the range fromRef..toRef.
*
* Both refs are validated with `git rev-parse --verify <ref>^{commit}` before
* the range is queried, so a typo'd tag/SHA surfaces as a clear error instead
* of an empty range that masquerades as "no changes".
*
* @param {string} fromRef - Exclusive lower bound (commit, tag, or branch)
* @param {string} toRef - Inclusive upper bound (default: 'HEAD')
* @param {Object} options - Optional filters
* @param {string} options.author - Filter by author name or email
* @returns {Promise<Array<{hash: string, message: string}>>} Commits in range
* @throws {Error} If either ref cannot be resolved to a commit
*/
async getCommitsBetween(fromRef, toRef = 'HEAD', options = {}) {
const validateRef = (ref) => {
const normalizedRef = String(ref).trim();
if (!normalizedRef) {
throw new Error('Unknown git ref: <empty>');
}
const resolved = this.gitManager.execGitSafe(`git rev-parse --verify "${normalizedRef}^{commit}"`);
if (!resolved || !resolved.trim()) {
throw new Error(`Unknown git ref: ${normalizedRef}`);
}
return normalizedRef;
};
const from = validateRef(fromRef);
const to = validateRef(toRef);
try {
let command = `git log ${from}..${to} --oneline`;
// Add author filter if specified
if (options.author) {
command += ` --author="${options.author}"`;
}
const output = this.gitManager.execGitSafe(command);
return output
.split('\n')
.filter(Boolean)
.map((line) => {
const [hash, ...messageParts] = line.split(' ');
return {
hash: hash.substring(0, 7),
message: messageParts.join(' '),
};
});
}
catch (error) {
const message = error?.message || String(error);
console.error(colors.errorMessage(`Error getting commits between ${from} and ${to}:`), message);
return [];
}
}
/**
* Process merge commit statistics to create meaningful file analysis
*/
processMergeCommitStats(commitHash, statOutput) {
if (!statOutput || statOutput.trim() === '') {
return [];
}
// Parse the git stat output to extract file information
const lines = statOutput.split('\n').filter(Boolean);
const files = [];
for (const line of lines) {
// Look for lines with file changes: "filename | additions +++--- deletions"
const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)\s*([+\-\s]+)/);
if (match) {
const [, filePath, changes, diffSymbols] = match;
const additions = (diffSymbols.match(/\+/g) || []).length;
const deletions = (diffSymbols.match(/-/g) || []).length;
// Determine file status based on changes
let status = 'M'; // Default to modified
if (additions > 0 && deletions === 0) {
status = 'A'; // Likely added
}
else if (deletions > 0 && additions === 0) {
status = 'D'; // Likely deleted
}
// Create a meaningful diff summary for the AI
const diff = this.createMergeCommitDiffSummary(filePath, parseInt(changes, 10), additions, deletions, commitHash);
files.push({
status,
filePath: filePath.trim(),
diff,
beforeContent: '',
afterContent: '',
category: categorizeFile(filePath),
language: detectLanguage(filePath),
importance: assessFileImportance(filePath, status),
complexity: {
level: changes > 100 ? 'high' : changes > 20 ? 'medium' : 'low',
},
semanticChanges: { patterns: ['merge-commit'] },
functionalImpact: { level: changes > 50 ? 'high' : 'medium' },
isMergeCommit: true,
changeCount: parseInt(changes, 10),
additions,
deletions,
});
}
}
return files;
}
/**
* Create a meaningful diff summary for merge commits with enhanced categorization
*/
createMergeCommitDiffSummary(filePath, totalChanges, additions, deletions, _commitHash) {
const changeType = additions > deletions ? 'expanded' : deletions > additions ? 'reduced' : 'modified';
let summary = `Merge commit changes: ${totalChanges} lines ${changeType}`;
if (additions > 0 && deletions > 0) {
summary += ` (${additions} added, ${deletions} removed)`;
}
else if (additions > 0) {
summary += ` (${additions} lines added)`;
}
else if (deletions > 0) {
summary += ` (${deletions} lines removed)`;
}
// Enhanced context based on file type and size of changes
if (filePath.includes('test')) {
summary += ' - Test infrastructure changes from merge';
}
else if (filePath.includes('config') || filePath.includes('.json')) {
summary += ' - Configuration and dependency changes from merge';
}
else if (filePath.includes('README') || filePath.includes('.md')) {
summary += ' - Documentation updates from merge';
}
else if (filePath.includes('src/domains/')) {
summary += ' - Core domain logic changes from merge';
}
else if (filePath.includes('src/infrastructure/')) {
summary += ' - Infrastructure and provider changes from merge';
}
else if (filePath.includes('src/application/')) {
summary += ' - Application service changes from merge';
}
else if (filePath.includes('bin/') || filePath.includes('cli')) {
summary += ' - CLI interface changes from merge';
}
else if (totalChanges > 100) {
summary += ' - Major code changes from merge';
}
else if (totalChanges > 20) {
summary += ' - Moderate code changes from merge';
}
else {
summary += ' - Minor code changes from merge';
}
return summary;
}
/**
* Generate comprehensive merge commit summary with categorized changes and technical details
*/
generateMergeCommitSummary(files, commitHash, subject) {
const categories = {
tests: { count: 0, lines: 0, files: [] },
docs: { count: 0, lines: 0, files: [] },
config: { count: 0, lines: 0, files: [] },
core: { count: 0, lines: 0, files: [] },
infrastructure: { count: 0, lines: 0, files: [] },
cli: { count: 0, lines: 0, files: [] },
other: { count: 0, lines: 0, files: [] },
};
let totalLines = 0;
let totalFiles = files.length;
// Categorize files and accumulate statistics
for (const file of files) {
const changes = file.changeCount || 0;
totalLines += changes;
if (file.filePath.includes('test')) {
categories.tests.count++;
categories.tests.lines += changes;
categories.tests.files.push(file.filePath);
}
else if (file.filePath.includes('.md') ||
file.filePath.includes('README') ||
file.filePath.includes('docs/')) {
categories.docs.count++;
categories.docs.lines += changes;
categories.docs.files.push(file.filePath);
}
else if (file.filePath.includes('config') ||
file.filePath.includes('.json') ||
file.filePath.includes('package.json')) {
categories.config.count++;
categories.config.lines += changes;
categories.config.files.push(file.filePath);
}
else if (file.filePath.includes('src/domains/')) {
categories.core.count++;
categories.core.lines += changes;
categories.core.files.push(file.filePath);
}
else if (file.filePath.includes('src/infrastructure/')) {
categories.infrastructure.count++;
categories.infrastructure.lines += changes;
categories.infrastructure.files.push(file.filePath);
}
else if (file.filePath.includes('bin/') || file.filePath.includes('cli')) {
categories.cli.count++;
categories.cli.lines += changes;
categories.cli.files.push(file.filePath);
}
else {
categories.other.count++;
categories.other.lines += changes;
categories.other.files.push(file.filePath);
}
}
// Extract technical details from key files for more specific descriptions
const technicalDetails = this.extractTechnicalDetailsFromMerge(files, commitHash);
// Generate detailed summary bullets with specifics
const bullets = [];
if (categories.tests.count > 0) {
const testSamples = categories.tests.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
bullets.push(`Added comprehensive test infrastructure with ${categories.tests.count} test files (${testSamples}${categories.tests.count > 3 ? ', ...' : ''}) totaling ${categories.tests.lines.toLocaleString()} lines of test code`);
}
if (categories.core.count > 0) {
const coreSamples = categories.core.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
const coreDetails = technicalDetails.filter((detail) => detail.includes('Enhanced') && detail.includes('.js'));
const detailSuffix = coreDetails.length > 0 ? ` with ${coreDetails.slice(0, 2).join(' and ')}` : '';
bullets.push(`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}`);
}
if (categories.infrastructure.count > 0) {
const infraSamples = categories.infrastructure.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
bullets.push(`Updated provider integrations and infrastructure services (${infraSamples}${categories.infrastructure.count > 3 ? ', ...' : ''}) across ${categories.infrastructure.count} files`);
}
if (categories.cli.count > 0) {
const cliSamples = categories.cli.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
bullets.push(`Improved CLI interface and command handling (${cliSamples}${categories.cli.count > 3 ? ', ...' : ''}) across ${categories.cli.count} files`);
}
if (categories.docs.count > 0) {
const docSamples = categories.docs.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
bullets.push(`Updated documentation and guides (${docSamples}${categories.docs.count > 3 ? ', ...' : ''}) across ${categories.docs.count} files`);
}
if (categories.config.count > 0) {
const configSamples = categories.config.files
.slice(0, 3)
.map((f) => f.split('/').pop())
.join(', ');
const configDetails = technicalDetails.filter((detail) => detail.includes('dependencies') ||
detail.includes('configuration') ||
detail.includes('.gitignore'));
const detailSuffix = configDetails.length > 0 ? ` including ${configDetails.slice(0, 2).join(' and ')}` : '';
bullets.push(`Modified configuration files and dependencies (${configSamples}${categories.config.count > 3 ? ', ...' : ''}) across ${categories.config.count} files${detailSuffix}`);
}
// Create enhanced merge commit description
let description = `${subject} brought together major updates across ${totalFiles} files with ${totalLines.toLocaleString()} total line changes:\n\n`;
if (bullets.length > 0) {
description += bullets.map((bullet) => ` • ${bullet}`).join('\n');
}
else {
description += ` • Major codebase changes across multiple modules and services`;
}
return description;
}
/**
* Extract technical details from key merge commit files for specific descriptions
*/
extractTechnicalDetailsFromMerge(files, commitHash) {
const details = [];
// Focus on key configuration and important files for technical details
const keyFiles = files
.filter((file) => {
const path = file.filePath.toLowerCase();
return (path.includes('package.json') ||
path.includes('.config.') ||
path.includes('biome.json') ||
path.includes('.gitignore') ||
path.endsWith('.md') ||
(path.includes('src/') && file.changeCount > 100)); // Major code changes
})
.slice(0, 5); // Limit to 5 key files to avoid overwhelming
for (const file of keyFiles) {
try {
// Get a sample of the actual diff for technical details
const diffCommand = `git show ${commitHash} --pretty=format: -U2 -- "${file.filePath}"`;
const diff = this.gitManager.execGitSafe(diffCommand);
if (diff && diff.length > 0) {
const techDetail = this.extractSpecificChanges(file.filePath, diff);
if (techDetail) {
details.push(techDetail);
}
}
}
catch (error) {
console.warn(colors.warningMessage(`Failed to extract technical details: ${error.message}`));
}
}
return details;
}
/**
* Extract specific technical changes from a diff
*/
extractSpecificChanges(filePath, diff) {
const fileName = filePath.split('/').pop();
// Package.json changes
if (fileName === 'package.json') {
const versionChanges = diff.match(/[-+]\s*"([^"]+)":\s*"([^"]+)"/g);
if (versionChanges && versionChanges.length > 0) {
const changes = versionChanges
.slice(0, 3)
.map((change) => {
const match = change.match(/[-+]\s*"([^"]+)":\s*"([^"]+)"/);
if (match) {
const [, pkg, version] = match;
return `${pkg} to ${version}`;
}
return change;
})
.join(', ');
return `Updated dependencies: ${changes}${versionChanges.length > 3 ? ', ...' : ''}`;
}
}
// Configuration file changes
if (fileName.includes('.json') || fileName.includes('.config')) {
const addedLines = diff
.split('\n')
.filter((line) => line.startsWith('+'))
.slice(0, 3);
if (addedLines.length > 0) {
const configChanges = addedLines
.map((line) => line.replace(/^\+\s*/, '').trim())
.filter((line) => line.length > 0);
if (configChanges.length > 0) {
return `Modified ${fileName} configuration: ${configChanges.slice(0, 2).join(', ')}${configChanges.length > 2 ? ', ...' : ''}`;
}
}
}
// .gitignore changes
if (fileName === '.gitignore') {
const removedPatterns = diff
.split('\n')
.filter((line) => line.startsWith('-'))
.map((line) => line.replace(/^-\s*/, '').trim())
.filter((line) => line.length > 0);
const addedPatterns = diff
.split('\n')
.filter((line) => line.startsWith('+'))
.map((line) => line.replace(/^\+\s*/, '').trim())
.filter((line) => line.length > 0);
if (removedPatterns.length > 0 || addedPatterns.length > 0) {
let change = '';
if (removedPatterns.length > 0) {
change += `removed ${removedPatterns.slice(0, 3).join(', ')} patterns`;
}
if (addedPatterns.length > 0) {
change += `${change ? ' and ' : ''}added ${addedPatterns.slice(0, 3).join(', ')} patterns`;
}
return `Updated .gitignore: ${change}`;
}
}
// Major code files - look for function/method additions
if (filePath.includes('src/') && fileName.endsWith('.js')) {
const functionMatches = diff.match(/\+.*(?:function|async|const|let|var)\s+(\w+)/g);
if (functionMatches && functionMatches.length > 0) {
const functions = functionMatches
.slice(0, 3)
.map((match) => {
const funcMatch = match.match(/\+.*(?:function|async|const|let|var)\s+(\w+)/);
return funcMatch ? funcMatch[1] : null;
})
.filter(Boolean);
if (functions.length > 0) {
return `Enhanced ${fileName}: added ${functions.join(', ')}${functionMatches.length > 3 ? ', ...' : ''} functions`;
}
}
}
return null;
}
// Repository analysis methods (formerly in GitRepositoryAnalyzer)
async assessRepositoryHealth(_config = {}) {
try {
const health = {
healthy: true,
score: 100,
issues: [],
recommendations: [],
metrics: {
branches: 0,
commits: 0,
untrackedFiles: 0,
staleBranches: 0,
largeBinaryFiles: 0,
commitFrequency: 0,
},
};
// Get basic repository statistics
try {
const branchesOutput = this.gitManager.execGitSafe('git branch -a');
health.metrics.branches = branchesOutput.split('\n').filter((line) => line.trim()).length;
const commitsOutput = this.gitManager.execGitSafe('git rev-list --all --count');
health.metrics.commits = parseInt(commitsOutput.trim(), 10) || 0;
const untrackedOutput = this.gitManager.execGitSafe('git ls-files --others --exclude-standard');
health.metrics.untrackedFiles = untrackedOutput
.split('\n')
.filter((line) => line.trim()).length;
}
catch (error) {
health.issues.push(`Failed to collect basic metrics: ${error.message}`);
health.score -= 10;
}
// Check for stale branches (no commits in last 90 days)
try {
const staleBranchesOutput = this.gitManager.execGitSafe('git for-each-ref --format="%(refname:short) %(committerdate:iso)" refs/heads');
const staleThreshold = new Date();
staleThreshold.setDate(staleThreshold.getDate() - 90);
health.metrics.staleBranches = staleBranchesOutput.split('\n').filter((line) => {
const parts = line.trim().split(' ');
if (parts.length < 2)
return false;
const commitDate = new Date(parts[1]);
return commitDate < staleThreshold;
}).length;
if (health.metrics.staleBranches > 5) {
health.issues.push(`${health.metrics.staleBranches} stale branches found (no commits in 90+ days)`);
health.recommendations.push('Consider cleaning up old branches with: git branch -d <branch-name>');
health.score -= Math.min(20, health.metrics.staleBranches * 2);
}
}
catch (error) {
console.warn(`Warning: Could not check for stale branches: ${error.message}`);
}
// Check for large binary files in repository
try {
const largeFilesOutput = this.gitManager.execGitSafe('git rev-list --objects --all | git cat-file --batch-check="%(objecttype) %(objectsize) %(rest)" | grep "^blob" | sort -nr -k2 | head -10');
const largeFiles = largeFilesOutput
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const parts = line.split(' ');
return {
size: parseInt(parts[1], 10),
path: parts.slice(2).join(' '),
};
})
.filter((file) => file.size > 10 * 1024 * 1024); // 10MB threshold
health.metrics.largeBinaryFiles = largeFiles.length;
if (largeFiles.length > 0) {
health.issues.push(`${largeFiles.length} large files found (>10MB)`);
health.recommendations.push('Consider using Git LFS for large binary files');
health.score -= Math.min(15, largeFiles.length * 5);
}
}
catch (error) {
console.warn(`Warning: Could not check for large files: ${error.message}`);
}
// Calculate commit frequency (commits per week over last month)
try {
const oneMonthAgo = new Date();
oneMonthAgo.setDate(oneMonthAgo.getDate() - 30);
const recentCommitsOutput = this.gitManager.execGitSafe(`git rev-list --count --since="${oneMonthAgo.toISOString()}" HEAD`);
const recentCommits = parseInt(recentCommitsOutput.trim(), 10) || 0;
health.metrics.commitFrequency = Math.round((recentCommits / 4) * 10) / 10; // commits per week
if (health.metrics.commitFrequency < 1) {
health.issues.push('Low commit frequency (less than 1 commit per week)');
health.recommendations.push('Consider more frequent commits for better project tracking');
health.score -= 5;
}
}
catch (error) {
console.warn(`Warning: Could not calculate commit frequency: ${error.message}`);
}
// Check if working directory is clean
try {
const statusOutput = this.gitManager.execGitSafe('git status --porcelain');
if (statusOutput.trim()) {
health.issues.push('Working directory has uncommitted changes');
health.recommendations.push('Commit or stash working directory changes');
health.score -= 5;
}
}
catch (error) {
health.issues.push(`Could not check working directory status: ${error.message}`);
health.score -= 5;
}
// Check for .gitignore file
try {
const gitignoreExists = this.gitManager.execGitSafe('test -f .gitignore && echo "exists"');
if (!gitignoreExists.includes('exists')) {
health.issues.push('No .gitignore file found');
health.recommendations.push('Add a .gitignore file to exclude unwanted files');
health.score -= 10;
}
}
catch (error) {
console.warn(`Warning: Could not check .gitignore: ${error.message}`);
}
// Determine overall health
health.healthy = health.score >= 70;
health.score = Math.max(0, health.score);
return health;
}
catch (error) {
console.error(`Repository health assessment failed: ${error.message}`);
return {
healthy: false,
score: 0,
issues: [`Health assessment failed: ${error.message}`],
recommendations: ['Ensure you are in a valid Git repository'],
metrics: createEmptyRepositoryMetrics(),
};
}
}
async analyzeBranches(format = 'markdown') {
try {
const analysis = {
branches: [],
unmergedCommits: [],
danglingCommits: [],
analysis: '',
};
// Get all branches with their last commit info
const branchesOutput = this.gitManager.execGitSafe('git for-each-ref --format="%(refname:short)|%(committerdate:iso)|%(authorname)|%(subject)" refs/heads refs/remotes');
analysis.branches = branchesOutput
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const [name, date, author, subject] = line.split('|');
const isRemote = name.startsWith('origin/');
const isStale = Date.now() - new Date(date).getTime() > 90 * 24 * 60 * 60 * 1000; // 90 days
return {
name: name.trim(),
lastCommitDate: date,
lastCommitAuthor: author,
lastCommitSubject: subject,
isRemote,
isStale,
type: isRemote ? 'remote' : 'local',
};
});
// Find unme