@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
302 lines (274 loc) • 11.2 kB
JavaScript
import { categorizeFile, detectLanguage, assessFileImportance, assessChangeComplexity, analyzeSemanticChanges, analyzeFunctionalImpact } from '../../shared/utils/utils.js';
import colors from '../../shared/constants/colors.js';
import { GitError } from '../../shared/utils/error-classes.js';
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();
// Get files with detailed analysis
const filesCommand = `git show --name-status --pretty=format: ${commitHash}`;
const filesOutput = this.gitManager.execGitSafe(filesCommand);
const 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 && 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 {
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 && 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 {
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 {
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 {
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 {
beforeContent = '';
}
try {
// Get current working directory version
const currentResult = this.gitManager.execGitSafe(`cat "${filePath}"`);
afterContent = currentResult ? currentResult.slice(0, 1000) : '';
} catch {
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' }
};
}
}
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[lines.length - 1];
if (summary && summary.includes('changed')) {
const match = summary.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
if (match) {
return {
files: parseInt(match[1]),
insertions: parseInt(match[2] || 0),
deletions: parseInt(match[3] || 0)
};
}
}
return { files: 0, insertions: 0, deletions: 0 };
} catch {
return { files: 0, insertions: 0, deletions: 0 };
}
}
async getCommitsSince(since) {
try {
const command = since
? `git log --oneline --since="${since}"`
: 'git log --oneline -10';
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) {
console.error(colors.errorMessage('Error getting commits since:'), error.message);
return [];
}
}
}