ds-sfcoe-ailabs
Version:
AI-powered code review tool with static analysis integration for comprehensive code quality assessment.
160 lines (159 loc) • 6.91 kB
JavaScript
import * as path from 'node:path';
import * as process from 'node:process';
import { simpleGit } from 'simple-git';
import fse from 'fs-extra';
// SimpleGit options
const options = {
binary: 'git',
maxConcurrentProcesses: 6,
trimmed: false,
};
const { execa } = await import('execa');
export default class GitHelper {
/**
* Create a temporary directory containing only the changed files between two Git references
*
* @param repoDir - The path to the Git repository directory
* @param from - The source commit/branch reference for diff comparison
* @param to - The target commit/branch reference for diff comparison
* @returns Promise resolving to the path of the temporary directory containing changed files
* @throws Error if Git operations fail or file copying encounters issues
*/
static async createDirectoryWithDiff(repoDir, from, to) {
options.baseDir = path.resolve(repoDir);
const tmpDir = path.join(options.baseDir, 'tmp-diff');
const git = simpleGit(options);
// Get changed files
const diff = await git.diff([`${from}..${to}`, '--name-only']);
const changedFiles = diff
.split('\n')
.filter((file) => file && !file.includes('staticresource'));
// Ensure temp directory is clean
fse.removeSync(tmpDir);
fse.ensureDirSync(tmpDir);
// Copy changed files to tmp preserving structure
for (const file of changedFiles) {
const src = path.join(repoDir, file);
const dest = path.join(tmpDir, file);
// Skip if source file doesn't exist (deleted files)
if (!fse.existsSync(src)) {
// Skip deleted files
continue;
}
fse.ensureDirSync(path.dirname(dest));
fse.copySync(src, dest);
}
return tmpDir;
}
/**
* Verify and resolve a commit SHA to its full form
*
* @param repoDir - The path to the Git repository directory
* @param commitSha - The commit SHA or reference to verify (can be short SHA, HEAD, branch name, etc.)
* @returns Promise resolving to the full commit SHA
* @throws Error if the commit SHA cannot be verified or resolved
*/
static async verifyCommitSha(repoDir, commitSha) {
options.baseDir = path.resolve(repoDir);
const git = simpleGit(options);
const commit = await git.revparse(['--verify', commitSha]);
return commit;
}
/**
* Gets changed line ranges for each file between two commits
*
* @param repoDir - The path to the Git repository directory
* @param from - The source commit/branch reference for diff comparison
* @param to - The target commit/branch reference for diff comparison
* @returns Promise resolving to a map of file paths to arrays of changed line ranges
* @throws Error if Git diff operation fails
* @example
* ```typescript
* const changedLines = await GitHelper.getChangedLineRanges('/path/to/repo', 'main', 'feature-branch');
* ```
*/
static async getChangedLineRanges(repoDir, from, to) {
const result = await execa('git', ['diff', '--unified=0', `${from}...${to}`], { cwd: repoDir });
const changedLines = new Map();
let currentFile = '';
for (const line of result.stdout.split('\n')) {
if (line.startsWith('+++')) {
// Extract filename from +++ b/filename
const match = line.match(/^\+\+\+ b\/(.+)$/);
if (match) {
currentFile = match[1];
}
}
else if (line.startsWith('@@')) {
// Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
if (match && currentFile) {
const startLine = parseInt(match[1], 10);
const lineCount = match[2] ? parseInt(match[2], 10) : 1;
const endLine = startLine + lineCount - 1;
if (!changedLines.has(currentFile)) {
changedLines.set(currentFile, []);
}
changedLines
.get(currentFile)
.push({ start: startLine, end: endLine });
}
}
}
return changedLines;
}
/**
* Gets the status of files changed between two commits
* Returns a map of file paths to their status ('A'=added, 'M'=modified, 'D'=deleted, 'R'=renamed)
*
* @param repoDir - The path to the Git repository directory
* @param from - The source commit/branch reference for diff comparison
* @param to - The target commit/branch reference for diff comparison
* @returns Promise resolving to a map of file paths to their Git status
* @throws Error if Git diff operation fails
* @example
* ```typescript
* const statuses = await GitHelper.getFileStatuses('/path/to/repo', 'main', 'feature-branch');
* console.log(statuses.get('src/file.ts')); // 'M' for modified, 'A' for added, etc.
* ```
*/
static async getFileStatuses(repoDir, from, to) {
const result = await execa('git', ['diff', '--name-status', `${from}...${to}`], { cwd: repoDir });
const fileStatuses = new Map();
for (const line of result.stdout.split('\n')) {
if (line.trim()) {
const parts = line.trim().split('\t');
if (parts.length >= 2) {
const status = parts[0].charAt(0); // Get first character (A, M, D, R, etc.)
const filename = parts[parts.length - 1]; // Get the last part (filename)
fileStatuses.set(filename, status);
}
}
}
return fileStatuses;
}
/**
* Finds the git root directory by traversing up the directory tree
*
* @param startDir - Optional starting directory to search from (defaults to current working directory)
* @returns Promise resolving to the absolute path of the Git repository root
* @throws Error if no Git repository is found or Git operation fails
* @example
* ```typescript
* const gitRoot = await GitHelper.findGitRoot('/path/to/subdirectory');
* console.log(gitRoot); // '/path/to/repo'
* ```
*/
static async findGitRoot(startDir) {
const cwd = startDir ?? process.cwd();
try {
const result = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
});
return result.stdout.trim();
}
catch (error) {
throw new Error(`Failed to find git root from ${cwd}: ${String(error)}`);
}
}
}