@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
477 lines (476 loc) • 16 kB
JavaScript
import { execSync } from 'node:child_process';
import { GitError } from '../../shared/utils/error-classes.js';
/**
* GitManager - Handles all Git command execution and repository operations
*
* This class provides a centralized interface for Git operations with proper
* error handling, command validation, and consistent output formatting.
*/
export class GitManager {
constructor(options = {}) {
this.options = {
encoding: 'utf8',
timeout: 30000,
stdio: 'pipe',
cwd: undefined, // Will use process.cwd() if not specified
...options,
};
// Cache git repository status
this._isGitRepoCache = null;
this._gitDirCache = null;
}
/**
* Check if current directory is a Git repository
* @returns {boolean} True if in a Git repository
*/
get isGitRepo() {
if (this._isGitRepoCache === null) {
this._isGitRepoCache = this._checkIsGitRepo();
}
return this._isGitRepoCache;
}
/**
* Get the Git directory path
* @returns {string|null} Path to .git directory or null if not a Git repo
*/
get gitDir() {
if (this._gitDirCache === null && this.isGitRepo) {
try {
this._gitDirCache = this.execGitSafe('git rev-parse --git-dir').trim();
}
catch {
this._gitDirCache = null;
}
}
return this._gitDirCache;
}
/**
* Execute a Git command with full error handling
* @param {string} command - The Git command to execute
* @returns {string} Command output
* @throws {GitError} If command fails
*/
execGit(command) {
try {
const execOptions = {
encoding: this.options.encoding,
stdio: this.options.stdio,
timeout: this.options.timeout,
};
// Add cwd if specified
if (this.options.cwd) {
execOptions.cwd = this.options.cwd;
}
return execSync(command, execOptions);
}
catch (error) {
throw this._createGitError(command, error);
}
}
/**
* Execute a Git command safely, returning empty string on failure
* @param {string} command - The Git command to execute
* @returns {string} Command output or empty string on failure
*/
execGitSafe(command) {
try {
return this.execGit(command);
}
catch {
return '';
}
}
/**
* Execute a Git show command with specific error handling for missing files
* @param {string} command - The Git show command to execute
* @returns {string|null} Command output or null if file doesn't exist
*/
execGitShow(command) {
try {
return this.execGit(command);
}
catch (error) {
// Return null for missing files rather than throwing
if (error.message.includes('does not exist') ||
error.message.includes('bad file') ||
error.message.includes('not found')) {
return null;
}
throw error;
}
}
/**
* Validate a commit hash exists in the repository
* @param {string} hash - The commit hash to validate
* @returns {boolean} True if commit exists
*/
validateCommitHash(hash) {
if (!hash || typeof hash !== 'string') {
return false;
}
// Basic format validation
if (!/^[a-f0-9]{6,40}$/i.test(hash)) {
return false;
}
try {
this.execGit(`git cat-file -e ${hash}`);
return true;
}
catch {
return false;
}
}
/**
* Get the current branch name
* @returns {string} Current branch name or 'HEAD' if detached
*/
getCurrentBranch() {
try {
return this.execGitSafe('git branch --show-current').trim() || 'HEAD';
}
catch {
return 'HEAD';
}
}
/**
* Get the remote URL for the repository
* @param {string} remoteName - Name of the remote (default: 'origin')
* @returns {string|null} Remote URL or null if not found
*/
getRemoteUrl(remoteName = 'origin') {
try {
return this.execGitSafe(`git remote get-url ${remoteName}`).trim() || null;
}
catch {
return null;
}
}
/**
* Get repository root directory
* @returns {string|null} Repository root path or null if not in a Git repo
*/
getRepositoryRoot() {
try {
return this.execGitSafe('git rev-parse --show-toplevel').trim() || null;
}
catch {
return null;
}
}
/**
* Check if working directory is clean (no uncommitted changes)
* @returns {boolean} True if working directory is clean
*/
isWorkingDirectoryClean() {
try {
const status = this.execGitSafe('git status --porcelain');
return status.trim() === '';
}
catch {
return false;
}
}
/**
* Get list of all tags in repository
* @returns {Array<string>} Array of tag names
*/
getAllTags() {
try {
const output = this.execGitSafe('git tag -l');
return output
.split('\n')
.filter((tag) => tag.trim())
.sort();
}
catch {
return [];
}
}
/**
* Get the latest tag
* @returns {string|null} Latest tag name or null if no tags
*/
getLatestTag() {
try {
return this.execGitSafe('git describe --tags --abbrev=0').trim() || null;
}
catch {
return null;
}
}
/**
* Get commits between two references
* @param {string} from - Starting reference (commit, tag, branch)
* @param {string} to - Ending reference (default: 'HEAD')
* @param {Object} options - Optional filters
* @param {string} options.author - Filter by author name or email
* @returns {Array<Object>} Array of commit objects
*/
getCommitsBetween(from, to = 'HEAD', options = {}) {
try {
let command = `git log ${from}..${to} --oneline`;
if (options.author) {
command += ` --author="${options.author}"`;
}
const output = this.execGitSafe(command);
return output
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const [hash, ...messageParts] = line.split(' ');
return {
hash: hash.trim(),
message: messageParts.join(' '),
};
});
}
catch {
return [];
}
}
/**
* Get commits filtered by author
* @param {string} author - Author name or email to filter by
* @param {number} limit - Maximum number of commits to return
* @returns {Array<Object>} Array of commit objects by this author
*/
getCommitsByAuthor(author, limit = 50) {
try {
const output = this.execGitSafe(`git log --author="${author}" --oneline -n ${limit}`);
return output
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const [hash, ...messageParts] = line.split(' ');
return {
hash: hash.trim(),
message: messageParts.join(' '),
};
});
}
catch {
return [];
}
}
/**
* Get commits between two tags
* @param {string} fromTag - Starting tag
* @param {string} toTag - Ending tag (default: 'HEAD')
* @param {Object} options - Optional filters
* @returns {Array<Object>} Array of commit objects between tags
*/
getCommitsBetweenTags(fromTag, toTag = 'HEAD', options = {}) {
return this.getCommitsBetween(fromTag, toTag, options);
}
/**
* Get list of all authors in repository
* @returns {Array<string>} Array of unique author names
*/
getAllAuthors() {
try {
const output = this.execGitSafe('git log --format="%an" | sort -u');
return output
.split('\n')
.filter((name) => name.trim())
.sort();
}
catch {
return [];
}
}
/**
* Get list of stashed changes
* @returns {Array<Object>} Array of stash entries with index, message, and date
*/
getStashList() {
try {
const output = this.execGitSafe('git stash list --format="%gd|%gs|%ci"');
if (!output.trim()) {
return [];
}
return output
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const [index, message, date] = line.split('|');
return {
index: index.trim(),
message: message.trim(),
date: date.trim(),
};
});
}
catch {
return [];
}
}
/**
* Get detailed information about a specific stash entry
* @param {string} stashRef - Stash reference (e.g., 'stash@{0}')
* @returns {Object|null} Stash details including files changed
*/
getStashDetails(stashRef = 'stash@{0}') {
try {
// Get stash message for the requested ref.
//
// `git stash list --format="%gs" -1 <ref>` ignores <ref> entirely: stash
// list does not accept a stash selector as a positional argument, so the
// top entry (stash@{0}) is always returned. Match the requested selector
// against the reflog-selector column instead, and fall back to resolving
// the subject directly from the stash commit object.
const normalizedRef = String(stashRef).trim();
const listOutput = this.execGitSafe('git stash list --format="%gd|%gs"');
const matchedEntry = listOutput
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.find((line) => line.split('|')[0]?.trim() === normalizedRef);
let message = matchedEntry ? matchedEntry.slice(matchedEntry.indexOf('|') + 1).trim() : '';
if (!message) {
message = this.execGitSafe(`git log -1 --format=%s ${normalizedRef}`).trim();
}
// Get files changed in stash
const statOutput = this.execGitSafe(`git stash show ${stashRef} --stat`);
const diffOutput = this.execGitSafe(`git stash show ${stashRef} -p`);
// Parse stat output for files
const files = statOutput
.split('\n')
.filter((line) => line.includes('|'))
.map((line) => {
const match = line.match(/^\s*(.+?)\s*\|\s*(\d+)/);
if (match) {
return {
path: match[1].trim(),
changes: parseInt(match[2], 10),
};
}
return null;
})
.filter(Boolean);
// Get summary stats
const summaryMatch = statOutput.match(/(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/);
return {
ref: stashRef,
message,
files,
diff: diffOutput,
stats: {
filesChanged: summaryMatch ? parseInt(summaryMatch[1], 10) : files.length,
insertions: summaryMatch && summaryMatch[2] ? parseInt(summaryMatch[2], 10) : 0,
deletions: summaryMatch?.[3] ? parseInt(summaryMatch[3], 10) : 0,
},
};
}
catch {
return null;
}
}
/**
* Check if there are any stashed changes
* @returns {boolean} True if stash has entries
*/
hasStashedChanges() {
return this.getStashList().length > 0;
}
/**
* Check if a file exists in a specific commit
* @param {string} commitHash - The commit to check
* @param {string} filePath - The file path to check
* @returns {boolean} True if file exists in the commit
*/
fileExistsInCommit(commitHash, filePath) {
try {
this.execGit(`git cat-file -e ${commitHash}:${filePath}`);
return true;
}
catch {
return false;
}
}
/**
* Get file content from a specific commit
* @param {string} commitHash - The commit to get the file from
* @param {string} filePath - The file path to retrieve
* @returns {string|null} File content or null if file doesn't exist
*/
getFileFromCommit(commitHash, filePath) {
try {
return this.execGitShow(`git show ${commitHash}:${filePath}`);
}
catch {
return null;
}
}
/**
* Reset internal caches (call when repository state might have changed)
*/
resetCache() {
this._isGitRepoCache = null;
this._gitDirCache = null;
}
/**
* Get detailed repository information
* @returns {Object} Repository information object
*/
getRepositoryInfo() {
if (!this.isGitRepo) {
return { isGitRepo: false };
}
return {
isGitRepo: true,
gitDir: this.gitDir,
repositoryRoot: this.getRepositoryRoot(),
currentBranch: this.getCurrentBranch(),
remoteUrl: this.getRemoteUrl(),
isClean: this.isWorkingDirectoryClean(),
latestTag: this.getLatestTag(),
totalTags: this.getAllTags().length,
};
}
// Private methods
/**
* Check if current directory is a Git repository
* @private
* @returns {boolean}
*/
_checkIsGitRepo() {
try {
const execOptions = {
stdio: 'ignore',
timeout: 5000,
};
if (this.options.cwd) {
execOptions.cwd = this.options.cwd;
}
execSync('git rev-parse --git-dir', execOptions);
return true;
}
catch {
return false;
}
}
/**
* Create a GitError from a command execution error
* @private
* @param {string} command - The Git command that failed
* @param {Error} error - The original error
* @returns {GitError}
*/
_createGitError(command, error) {
const gitCommand = command.replace(/^git\s+/, '');
// Enhanced error handling with more specific messages
if (error.code === 128) {
if (error.message.includes('not a git repository')) {
return GitError.fromCommandFailure(gitCommand, null, null, 'Not in a git repository', error);
}
return GitError.fromCommandFailure(gitCommand, null, null, `Git repository error: ${error.message}`, error);
}
if (error.code === 129) {
return GitError.fromCommandFailure(gitCommand, null, null, `Git command syntax error: ${command}`, error);
}
if (error.code === 'ENOENT') {
return GitError.fromCommandFailure(gitCommand, null, null, 'Git is not installed or not in PATH', error);
}
return GitError.fromCommandFailure(gitCommand, null, null, `Git command failed: ${error.message}`, error);
}
}