import { simpleGit, SimpleGit, DiffResult } from 'simple-git'; import { DiffProviderPort, DiffOptions } from '../../domain/ports/DiffProviderPort'; import { Diff, DiffHunk, DiffLine } from '../../domain/entities/Diff'; export class GitAdapter implements DiffProviderPort { private git: SimpleGit; constructor(repoPath: string = process.cwd()) { this.git = simpleGit(repoPath); } async getDiffs(options: DiffOptions = {}): Promise { try { const { baseRef, headRef, includeStaged, includeUnstaged, filePatterns, excludePatterns } = options; let diffResult: DiffResult; if (baseRef && headRef) { // Get diff between two specific commits const rawDiff = await this.git.diff([baseRef, headRef]); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else if (includeStaged) { // Get staged changes const rawDiff = await this.git.diff(['--cached']); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else if (includeUnstaged) { // Get unstaged changes const rawDiff = await this.git.diff(); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else { // Get diff from HEAD const rawDiff = await this.git.diff(['HEAD~1', 'HEAD']); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } return this.parseDiffResult(diffResult, filePatterns, excludePatterns); } catch (error) { throw new Error(`Failed to get diffs: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getFileDiff(filePath: string, options: DiffOptions = {}): Promise { try { const { baseRef, headRef, includeStaged, includeUnstaged } = options; let diffResult: DiffResult; if (baseRef && headRef) { const rawDiff = await this.git.diff([baseRef, headRef, '--', filePath]); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else if (includeStaged) { const rawDiff = await this.git.diff(['--cached', '--', filePath]); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else if (includeUnstaged) { const rawDiff = await this.git.diff(['--', filePath]); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } else { const rawDiff = await this.git.diff(['HEAD~1', 'HEAD', '--', filePath]); diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; } const diffs = this.parseDiffResult(diffResult); return diffs.length > 0 ? diffs[0] ?? null : null; } catch (error) { throw new Error(`Failed to get file diff for ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getCommitDiff(baseCommit: string, headCommit: string, options: DiffOptions = {}): Promise { try { const { filePatterns, excludePatterns } = options; const rawDiff = await this.git.diff([baseCommit, headCommit]); const diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; return this.parseDiffResult(diffResult, filePatterns, excludePatterns); } catch (error) { throw new Error(`Failed to get commit diff: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getStagedDiff(options: DiffOptions = {}): Promise { try { const { filePatterns, excludePatterns } = options; const rawDiff = await this.git.diff(['--cached']); const diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; return this.parseDiffResult(diffResult, filePatterns, excludePatterns); } catch (error) { throw new Error(`Failed to get staged diff: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async getUnstagedDiff(options: DiffOptions = {}): Promise { try { const { filePatterns, excludePatterns } = options; const rawDiff = await this.git.diff(); const diffResult = { diff: rawDiff, insertions: 0, deletions: 0, files: [] } as unknown as DiffResult; return this.parseDiffResult(diffResult, filePatterns, excludePatterns); } catch (error) { throw new Error(`Failed to get unstaged diff: ${error instanceof Error ? error.message : 'Unknown error'}`); } } async hasChanges(filePath: string, options: DiffOptions = {}): Promise { try { const { includeStaged, includeUnstaged } = options; if (includeStaged) { const stagedStatus = await this.git.status(); return stagedStatus.staged.includes(filePath); } if (includeUnstaged) { const unstagedStatus = await this.git.status(); return unstagedStatus.modified.includes(filePath) || unstagedStatus.not_added.includes(filePath) || unstagedStatus.deleted.includes(filePath); } // Check if file has changes in last commit const diffResult = await this.git.diff(['HEAD~1', 'HEAD', '--', filePath]); return diffResult.length > 0; } catch (error) { return false; } } async getCommitInfo(commitHash: string): Promise<{ readonly hash: string; readonly message: string; readonly author: string; readonly date: Date; readonly files: string[]; } | null> { try { const log = await this.git.log({ from: commitHash, to: commitHash, maxCount: 1 }); if (log.total === 0) { return null; } const commit = log.latest; if (!commit) { return null; } // Get files changed in this commit const rawDiff = await this.git.diff([`${commitHash}~1`, commitHash, '--name-only']); const files = rawDiff ? rawDiff.split('\n').filter((file: string) => file.trim()) : []; return { hash: commit.hash, message: commit.message, author: commit.author_name, date: new Date(commit.date), files, }; } catch (error) { return null; } } private parseDiffResult(diffResult: DiffResult, filePatterns?: string[], excludePatterns?: string[]): Diff[] { const diffContent = (diffResult as any).diff; if (!diffContent) { return []; } const diffs: Diff[] = []; const lines = diffContent.split('\n'); let currentFile: Partial | null = null; let currentHunk: Partial | null = null; let hunkLines: DiffLine[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // File header if (line.startsWith('diff --git')) { // Save previous file if exists if (currentFile && currentHunk) { this.saveHunk(currentFile, currentHunk, hunkLines); diffs.push(this.buildDiff(currentFile)); } // Start new file const fileMatch = line.match(/diff --git a\/(.+) b\/(.+)/); if (fileMatch) { currentFile = { filePath: fileMatch[2], hunks: [], isNewFile: false, isDeletedFile: false, isBinary: false, } as any; } } // Binary file indicator else if (line.startsWith('Binary files')) { if (currentFile) { (currentFile as any).isBinary = true; } } // New file indicator else if (line.startsWith('new file mode')) { if (currentFile) { (currentFile as any).isNewFile = true; } } // Deleted file indicator else if (line.startsWith('deleted file mode')) { if (currentFile) { (currentFile as any).isDeletedFile = true; } } // Hunk header else if (line.startsWith('@@')) { // Save previous hunk if exists if (currentFile && currentHunk) { this.saveHunk(currentFile, currentHunk, hunkLines); } // Start new hunk const hunkMatch = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/); if (hunkMatch && currentFile) { currentHunk = { oldStart: parseInt(hunkMatch[1]), oldLines: parseInt(hunkMatch[2]) || 1, newStart: parseInt(hunkMatch[3]), newLines: parseInt(hunkMatch[4]) || 1, content: line, lines: [], } as any; hunkLines = []; } } // Diff line else if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) { if (currentHunk) { const diffLine: DiffLine = { type: line.startsWith('+') ? 'added' : line.startsWith('-') ? 'removed' : 'context', content: line.substring(1), lineNumber: i, }; hunkLines.push(diffLine); } } } // Save last file and hunk if (currentFile && currentHunk) { this.saveHunk(currentFile, currentHunk, hunkLines); diffs.push(this.buildDiff(currentFile)); } // Filter by patterns return this.filterDiffs(diffs, filePatterns, excludePatterns); } private saveHunk(currentFile: Partial, currentHunk: Partial, hunkLines: DiffLine[]): void { if (currentHunk && currentFile.hunks) { (currentHunk as any).lines = hunkLines; currentFile.hunks.push(currentHunk as DiffHunk); } } private buildDiff(currentFile: Partial): Diff { if (!currentFile.filePath || !currentFile.hunks) { throw new Error('Invalid diff structure'); } // Determine language from file extension const language = this.getLanguageFromExtension(currentFile.filePath); // Get old and new content const oldContent = this.getOldContent(currentFile); const newContent = this.getNewContent(currentFile); return { filePath: currentFile.filePath, oldContent, newContent, hunks: currentFile.hunks, language, isNewFile: currentFile.isNewFile || false, isDeletedFile: currentFile.isDeletedFile || false, isBinary: currentFile.isBinary || false, }; } private getLanguageFromExtension(filePath: string): string { const extension = filePath.split('.').pop()?.toLowerCase(); const languageMap: Record = { 'ts': 'typescript', 'js': 'javascript', 'tsx': 'typescript', 'jsx': 'javascript', 'py': 'python', 'java': 'java', 'cpp': 'cpp', 'c': 'c', 'cs': 'csharp', 'php': 'php', 'rb': 'ruby', 'go': 'go', 'rs': 'rust', 'swift': 'swift', 'kt': 'kotlin', 'scala': 'scala', 'html': 'html', 'css': 'css', 'scss': 'scss', 'less': 'less', 'json': 'json', 'xml': 'xml', 'yaml': 'yaml', 'yml': 'yaml', 'md': 'markdown', 'sql': 'sql', 'sh': 'bash', 'bash': 'bash', }; return languageMap[extension || ''] || 'text'; } private getOldContent(currentFile: Partial): string { if (!currentFile.hunks) return ''; return currentFile.hunks .flatMap((hunk) => hunk.lines || []) .filter((line) => line.type === 'removed' || line.type === 'context') .map((line) => line.content) .join('\n'); } private getNewContent(currentFile: Partial): string { if (!currentFile.hunks) return ''; return currentFile.hunks .flatMap((hunk) => hunk.lines || []) .filter((line) => line.type === 'added' || line.type === 'context') .map((line) => line.content) .join('\n'); } private filterDiffs(diffs: Diff[], filePatterns?: string[], excludePatterns?: string[]): Diff[] { let filteredDiffs = diffs; if (filePatterns && filePatterns.length > 0) { filteredDiffs = filteredDiffs.filter((diff) => filePatterns.some((pattern) => diff.filePath.includes(pattern)) ); } if (excludePatterns && excludePatterns.length > 0) { filteredDiffs = filteredDiffs.filter((diff) => !excludePatterns.some((pattern) => diff.filePath.includes(pattern)) ); } return filteredDiffs; } }