Git MCP Server
by cyanheads
Verified
- src
- services
/**
* Git Service
* ===========
*
* An abstraction layer for Git operations using simple-git.
* Provides a clean interface for the MCP server to interact with Git repositories.
*/
import { simpleGit } from 'simple-git';
type SimpleGit = any;
type SimpleGitOptions = any;
import fs from 'fs/promises';
import path from 'path';
import {
GitRepositoryOptions,
GitCommitOptions,
GitBranchOptions,
GitMergeOptions,
GitRemoteOptions,
GitPullOptions,
GitPushOptions,
GitTagOptions,
GitStashOptions,
GitRepositoryStatus,
GitLogEntry,
GitDiffEntry,
GitError
} from '../types/git.js';
import {
createGitError,
createSuccessResult,
createFailureResult,
OperationResult,
StandardizedApplicationErrorObject,
wrapExceptionAsStandardizedError
} from './error-service.js';
export class GitService {
private git: SimpleGit;
private repoPath: string;
/**
* Creates a new GitService instance for a specific repository path
*
* @param repoPath - Path to the git repository
* @param options - Additional simple-git options
*/
constructor(repoPath: string, options: SimpleGitOptions = {}) {
this.repoPath = repoPath;
this.git = simpleGit(this.repoPath, options);
}
/**
* Handles Git errors in a standardized way
*
* @param error - The error to handle
* @param defaultMessage - Default message if error is not a Git error
* @returns Standardized error object
*/
private handleGitError(error: unknown, defaultMessage: string): StandardizedApplicationErrorObject {
if ((error as GitError).code) {
const gitError = error as GitError;
return createGitError(
gitError.message || defaultMessage,
gitError.code || 'GIT_ERROR',
{
command: gitError.command,
args: gitError.args,
stderr: gitError.stderr
}
);
}
return wrapExceptionAsStandardizedError(error, defaultMessage);
}
/**
* Ensures the repository directory exists
*
* @returns Promise resolving when directory exists or is created
*/
private async ensureRepoPathExists(): Promise<void> {
try {
await fs.access(this.repoPath);
} catch (error) {
// Create directory if it doesn't exist
await fs.mkdir(this.repoPath, { recursive: true });
}
}
/**
* Checks if a path is a Git repository
*
* @param dirPath - Path to check
* @returns Promise resolving to true if path is a Git repository
*/
async isGitRepository(dirPath: string = this.repoPath): Promise<boolean> {
try {
const gitDir = path.join(dirPath, '.git');
await fs.access(gitDir);
return true;
} catch (error) {
try {
// Check if it's a bare repository by looking for common Git files
const gitFiles = ['HEAD', 'config', 'objects', 'refs'];
for (const file of gitFiles) {
await fs.access(path.join(dirPath, file));
}
return true;
} catch {
return false;
}
}
}
// ==========================================
// Repository Operations
// ==========================================
/**
* Initializes a new Git repository
*
* @param bare - Whether to create a bare repository
* @returns Promise resolving to operation result
*/
async initRepo(bare = false): Promise<OperationResult<string>> {
try {
await this.ensureRepoPathExists();
const result = await this.git.init(bare);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to initialize repository')
);
}
}
/**
* Clones a Git repository
*
* @param url - URL of the repository to clone
* @param options - Clone options
* @returns Promise resolving to operation result
*/
async cloneRepo(url: string, options: GitRepositoryOptions = {}): Promise<OperationResult<string>> {
try {
await this.ensureRepoPathExists();
const result = await this.git.clone(url, this.repoPath, options);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to clone repository from ${url}`)
);
}
}
/**
* Gets the status of the repository
*
* @returns Promise resolving to repository status
*/
async getStatus(): Promise<OperationResult<GitRepositoryStatus>> {
try {
const status = await this.git.status();
return createSuccessResult(status);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to get repository status')
);
}
}
// ==========================================
// Commit Operations
// ==========================================
/**
* Stages files for commit
*
* @param files - Array of file paths to stage, or '.' for all
* @returns Promise resolving to operation result
*/
async stageFiles(files: string[] | string = '.'): Promise<OperationResult<string>> {
try {
if (Array.isArray(files) && files.length === 0) {
return createSuccessResult('No files to stage');
}
const result = await this.git.add(files);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to stage files')
);
}
}
/**
* Unstages files
*
* @param files - Array of file paths to unstage, or '.' for all
* @returns Promise resolving to operation result
*/
async unstageFiles(files: string[] | string = '.'): Promise<OperationResult<string>> {
try {
if (Array.isArray(files) && files.length === 0) {
return createSuccessResult('No files to unstage');
}
// Use reset to unstage files
const result = await this.git.reset(['--', ...(Array.isArray(files) ? files : [files])]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to unstage files')
);
}
}
/**
* Creates a commit
*
* @param options - Commit options
* @returns Promise resolving to commit hash
*/
async commit(options: GitCommitOptions): Promise<OperationResult<string>> {
try {
const commitOptions: any = {
'--message': options.message
};
if (options.author) {
if (options.author.name) commitOptions['--author'] = options.author.name;
if (options.author.email) commitOptions['--author'] += ` <${options.author.email}>`;
}
if (options.allowEmpty) commitOptions['--allow-empty'] = null;
if (options.amend) commitOptions['--amend'] = null;
const result = await this.git.commit(options.message, commitOptions);
return createSuccessResult(result.commit || '');
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to create commit')
);
}
}
// ==========================================
// Branch Operations
// ==========================================
/**
* Creates a new branch
*
* @param options - Branch options
* @returns Promise resolving to operation result
*/
async createBranch(options: GitBranchOptions): Promise<OperationResult<string>> {
try {
const branchParams = [options.name];
if (options.startPoint) branchParams.push(options.startPoint);
await this.git.branch(branchParams);
if (options.checkout) {
await this.git.checkout(options.name);
}
return createSuccessResult(`Branch '${options.name}' created successfully`);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to create branch '${options.name}'`)
);
}
}
/**
* Lists all branches
*
* @param all - Whether to include remote branches
* @returns Promise resolving to list of branches
*/
async listBranches(all = false): Promise<OperationResult<string[]>> {
try {
const branchSummary = await this.git.branch(all ? ['-a'] : []);
return createSuccessResult(Object.keys(branchSummary.branches));
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to list branches')
);
}
}
/**
* Checkout a branch or commit
*
* @param target - Branch name, commit hash, or reference to checkout
* @param createBranch - Whether to create the branch if it doesn't exist
* @returns Promise resolving to operation result
*/
async checkout(target: string, createBranch = false): Promise<OperationResult<string>> {
try {
const options = createBranch ? ['-b'] : [];
const result = await this.git.checkout([...options, target]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to checkout '${target}'`)
);
}
}
/**
* Delete a branch
*
* @param branchName - Name of the branch to delete
* @param force - Whether to force delete
* @returns Promise resolving to operation result
*/
async deleteBranch(branchName: string, force = false): Promise<OperationResult<string>> {
try {
const options = force ? ['-D'] : ['-d'];
const result = await this.git.branch([...options, branchName]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to delete branch '${branchName}'`)
);
}
}
/**
* Merge a branch into the current branch
*
* @param options - Merge options
* @returns Promise resolving to merge result
*/
async merge(options: GitMergeOptions): Promise<OperationResult<string>> {
try {
const mergeParams = [options.branch];
if (options.fastForwardOnly) mergeParams.unshift('--ff-only');
if (options.noFastForward) mergeParams.unshift('--no-ff');
if (options.message) {
mergeParams.unshift('-m', options.message);
}
const result = await this.git.merge(mergeParams);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to merge branch '${options.branch}'`)
);
}
}
// ==========================================
// Remote Operations
// ==========================================
/**
* Add a remote
*
* @param options - Remote options
* @returns Promise resolving to operation result
*/
async addRemote(options: GitRemoteOptions): Promise<OperationResult<string>> {
try {
const result = await this.git.addRemote(options.name, options.url);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to add remote '${options.name}'`)
);
}
}
/**
* List remotes
*
* @returns Promise resolving to list of remotes
*/
async listRemotes(): Promise<OperationResult<Array<{name: string, refs: {fetch: string, push: string}}>>> {
try {
const remotes = await this.git.getRemotes(true);
return createSuccessResult(remotes);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to list remotes')
);
}
}
/**
* Fetch from a remote
*
* @param remote - Remote to fetch from (default: origin)
* @param branch - Branch to fetch (default: all branches)
* @returns Promise resolving to fetch result
*/
async fetch(remote = 'origin', branch?: string): Promise<OperationResult<string>> {
try {
const options = branch ? [remote, branch] : [remote];
const result = await this.git.fetch(options);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to fetch from remote '${remote}'`)
);
}
}
/**
* Pull from a remote
*
* @param options - Pull options
* @returns Promise resolving to pull result
*/
async pull(options: GitPullOptions = {}): Promise<OperationResult<string>> {
try {
const pullOptions: Record<string, any> = {};
if (options.remote) pullOptions.remote = options.remote;
if (options.branch) pullOptions.branch = options.branch;
if (options.rebase) pullOptions['--rebase'] = null;
const result = await this.git.pull(pullOptions);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to pull changes')
);
}
}
/**
* Push to a remote
*
* @param options - Push options
* @returns Promise resolving to push result
*/
async push(options: GitPushOptions = {}): Promise<OperationResult<string>> {
try {
const pushOptions: string[] = [];
if (options.force) pushOptions.push('--force');
if (options.setUpstream) pushOptions.push('--set-upstream');
const remote = options.remote || 'origin';
const branch = options.branch || 'HEAD';
const result = await this.git.push(remote, branch, pushOptions);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to push changes')
);
}
}
// ==========================================
// History Operations
// ==========================================
/**
* Get commit history
*
* @param options - Options for git log
* @returns Promise resolving to commit history
*/
async getLog(options: {maxCount?: number, file?: string} = {}): Promise<OperationResult<GitLogEntry[]>> {
try {
const logOptions: Record<string, any> = {
'--pretty': 'format:%H|%h|%an|%ae|%ai|%s'
};
// Use array format for command-line options to avoid format issues
const logParams = [`-n`, `${options.maxCount || 50}`];
if (options.file) {
logOptions['--'] = options.file;
}
// Pass the maxCount parameter separately to avoid the '=' format issue
const result = await this.git.log([...logParams, logOptions]);
// Parse the log output into structured data
const entries: GitLogEntry[] = result.all.map((entry: any) => ({
hash: entry.hash,
abbrevHash: entry.hash.substring(0, 7),
author: entry.author_name,
authorEmail: entry.author_email,
date: new Date(entry.date),
message: entry.message,
refs: entry.refs
}));
return createSuccessResult(entries);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to get commit history')
);
}
}
/**
* Get file blame information
*
* @param filePath - Path to the file
* @returns Promise resolving to blame information
*/
async getBlame(filePath: string): Promise<OperationResult<string>> {
try {
const result = await this.git.raw(['blame', filePath]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to get blame for file '${filePath}'`)
);
}
}
/**
* Get the diff between commits
*
* @param fromRef - Starting reference (commit, branch, etc.)
* @param toRef - Ending reference (default: current working tree)
* @param path - Optional path to restrict the diff to
* @returns Promise resolving to diff information
*/
async getDiff(fromRef: string, toRef = 'HEAD', path?: string): Promise<OperationResult<GitDiffEntry[]>> {
try {
const args = ['diff', '--name-status', fromRef];
if (toRef !== 'HEAD') {
args.push(toRef);
}
if (path) {
args.push('--', path);
}
const result = await this.git.raw(args);
const entries: GitDiffEntry[] = [];
// Parse the diff output into structured data
const lines = result.split('\n').filter((line: string) => line.trim() !== '');
for (const line of lines) {
const [status, ...pathParts] = line.split('\t');
const filePath = pathParts.join('\t');
entries.push({
path: filePath,
// Mapping status letters to more descriptive terms
status: status === 'A' ? 'added' :
status === 'M' ? 'modified' :
status === 'D' ? 'deleted' :
status === 'R' ? 'renamed' :
status === 'C' ? 'copied' : status
});
}
return createSuccessResult(entries);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to get diff')
);
}
}
/**
* Get the content of a file at a specific reference
*
* @param filePath - Path to the file
* @param ref - Git reference (commit, branch, etc.)
* @returns Promise resolving to file content
*/
async getFileAtRef(filePath: string, ref = 'HEAD'): Promise<OperationResult<string>> {
try {
const result = await this.git.show([`${ref}:${filePath}`]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to get file '${filePath}' at ref '${ref}'`)
);
}
}
/**
* Get unstaged diff (changes in working directory)
*
* @param path - Optional path to restrict the diff to
* @param showUntracked - Whether to include information about untracked files
* @returns Promise resolving to diff content
*/
async getUnstagedDiff(path?: string, showUntracked = true): Promise<OperationResult<string>> {
try {
const args = ['diff'];
if (path) {
args.push('--', path);
}
let diffResult = await this.git.raw(args);
// If requested, also include information about untracked files
if (showUntracked) {
try {
// Get status to find untracked files
const statusResult = await this.getStatus();
if (statusResult.resultSuccessful && statusResult.resultData.not_added.length > 0) {
// Filter untracked files by path if specified
const untrackedFiles = path
? statusResult.resultData.not_added.filter(file => file === path || file.startsWith(path + '/'))
: statusResult.resultData.not_added;
if (untrackedFiles.length > 0) {
// Add header for untracked files if we have a diff and untracked files
if (diffResult.trim() !== '') {
diffResult += '\n\n';
}
diffResult += '# Untracked files:\n';
for (const file of untrackedFiles) {
diffResult += `# - ${file}\n`;
}
}
}
} catch (error) {
// Silently ignore errors with listing untracked files
console.error('Error listing untracked files:', error);
}
}
return createSuccessResult(diffResult);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to get unstaged diff')
);
}
}
/**
* Get staged diff (changes in index)
*
* @param path - Optional path to restrict the diff to
* @returns Promise resolving to diff content
*/
async getStagedDiff(path?: string): Promise<OperationResult<string>> {
try {
const args = ['diff', '--cached'];
if (path) {
args.push('--', path);
}
const result = await this.git.raw(args);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to get staged diff')
);
}
}
/**
* List files in a directory at a specific reference
*
* @param dirPath - Path to the directory (relative to the repo root)
* @param ref - Git reference (commit, branch, etc.)
* @returns Promise resolving to list of files
*/
async listFilesAtRef(dirPath: string = '.', ref = 'HEAD'): Promise<OperationResult<string[]>> {
try {
const result = await this.git.raw(['ls-tree', '-r', '--name-only', ref, dirPath]);
// Parse the output
const files = result.split('\n')
.filter((line: string) => line.trim() !== '')
.filter((file: string) => {
// If dirPath is empty or root, include all files
if (!dirPath || dirPath === '.') {
return true;
}
// Otherwise, only include files that are within the directory
return file.startsWith(dirPath + '/');
});
return createSuccessResult(files);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to list files in directory '${dirPath}' at ref '${ref}'`)
);
}
}
// ==========================================
// Advanced Operations
// ==========================================
/**
* Create a tag
*
* @param options - Tag options
* @returns Promise resolving to operation result
*/
async createTag(options: GitTagOptions): Promise<OperationResult<string>> {
try {
const tagArgs = [options.name];
if (options.ref) {
tagArgs.push(options.ref);
}
if (options.message) {
tagArgs.unshift('-m', options.message);
// -a creates an annotated tag
tagArgs.unshift('-a');
}
const result = await this.git.tag(tagArgs);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to create tag '${options.name}'`)
);
}
}
/**
* List tags
*
* @returns Promise resolving to list of tags
*/
async listTags(): Promise<OperationResult<string[]>> {
try {
const tags = await this.git.tags();
return createSuccessResult(tags.all);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to list tags')
);
}
}
/**
* Create a stash
*
* @param options - Stash options
* @returns Promise resolving to operation result
*/
async createStash(options: GitStashOptions = {}): Promise<OperationResult<string>> {
try {
const stashArgs: string[] = [];
if (options.message) {
stashArgs.push('save', options.message);
}
if (options.includeUntracked) {
stashArgs.push('--include-untracked');
}
const result = await this.git.stash(stashArgs);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to create stash')
);
}
}
/**
* List stashes
*
* @returns Promise resolving to list of stashes
*/
async listStashes(): Promise<OperationResult<string>> {
try {
const result = await this.git.stash(['list']);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to list stashes')
);
}
}
/**
* Show a commit's details
*
* @param commitHash - Hash of the commit to show
* @returns Promise resolving to commit details
*/
async showCommit(commitHash: string): Promise<OperationResult<string>> {
try {
const result = await this.git.raw(['show', commitHash]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to show commit '${commitHash}'`)
);
}
}
/**
* Apply a stash
*
* @param stashId - Stash identifier (default: most recent stash)
* @returns Promise resolving to operation result
*/
async applyStash(stashId = 'stash@{0}'): Promise<OperationResult<string>> {
try {
const result = await this.git.stash(['apply', stashId]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to apply stash '${stashId}'`)
);
}
}
/**
* Pop a stash
*
* @param stashId - Stash identifier (default: most recent stash)
* @returns Promise resolving to operation result
*/
async popStash(stashId = 'stash@{0}'): Promise<OperationResult<string>> {
try {
const result = await this.git.stash(['pop', stashId]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to pop stash '${stashId}'`)
);
}
}
/**
* Cherry-pick commits
*
* @param commits - Array of commit hashes to cherry-pick
* @returns Promise resolving to operation result
*/
async cherryPick(commits: string[]): Promise<OperationResult<string>> {
try {
if (commits.length === 0) {
return createSuccessResult('No commits specified for cherry-pick');
}
const result = await this.git.raw(['cherry-pick', ...commits]);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to cherry-pick commits')
);
}
}
/**
* Rebase the current branch
*
* @param branch - Branch to rebase onto
* @param interactive - Whether to use interactive rebase
* @returns Promise resolving to operation result
*/
async rebase(branch: string, interactive = false): Promise<OperationResult<string>> {
try {
const args = interactive ? ['-i', branch] : [branch];
const result = await this.git.rebase(args);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to rebase onto '${branch}'`)
);
}
}
/**
* Reset the repository to a specific commit
*
* @param ref - Reference to reset to
* @param mode - Reset mode (hard, soft, mixed)
* @returns Promise resolving to operation result
*/
async reset(ref = 'HEAD', mode: 'hard' | 'soft' | 'mixed' = 'mixed'): Promise<OperationResult<string>> {
try {
const args = [`--${mode}`, ref];
const result = await this.git.reset(args);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, `Failed to reset to '${ref}'`)
);
}
}
/**
* Clean the working directory
*
* @param directories - Whether to remove directories too
* @param force - Whether to force clean
* @returns Promise resolving to operation result
*/
async clean(directories = false, force = false): Promise<OperationResult<string>> {
try {
const args = ['-f'];
if (directories) args.push('-d');
if (force) args.push('-x');
const result = await this.git.clean(args);
return createSuccessResult(result);
} catch (error) {
return createFailureResult(
this.handleGitError(error, 'Failed to clean working directory')
);
}
}
}