UNPKG

@sun-asterisk/sunlint

Version:

☀️ SunLint - Multi-language static analysis tool for code quality and security | Sun* Engineering Standards

486 lines (425 loc) 15.3 kB
/** * Git Utils - Handle git operations for file filtering * Following Rule C005: Single responsibility - only handle git operations * Following Rule C014: Dependency injection for git operations */ const { execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); class GitUtils { /** * Check if current directory is a git repository */ static isGitRepository(cwd = process.cwd()) { try { execSync('git rev-parse --git-dir', { cwd, stdio: 'ignore' }); return true; } catch (error) { return false; } } /** * Detect if running in PR context (GitHub Actions, GitLab CI, etc.) * @returns {Object|null} PR context info or null */ static detectPRContext() { // GitHub Actions if (process.env.GITHUB_EVENT_NAME === 'pull_request' || process.env.GITHUB_EVENT_NAME === 'pull_request_target') { return { provider: 'github', baseBranch: process.env.GITHUB_BASE_REF, headBranch: process.env.GITHUB_HEAD_REF, prNumber: process.env.GITHUB_REF ? process.env.GITHUB_REF.match(/refs\/pull\/(\d+)\/merge/)?.[1] : null }; } // GitLab CI if (process.env.CI_MERGE_REQUEST_ID) { return { provider: 'gitlab', baseBranch: process.env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME, headBranch: process.env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME, prNumber: process.env.CI_MERGE_REQUEST_IID }; } return null; } /** * Get smart base reference for diff * Auto-detects PR context or falls back to common base branches * @param {string} cwd - Working directory * @returns {string} Base reference for git diff */ static getSmartBaseRef(cwd = process.cwd()) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } // Check PR context first const prContext = this.detectPRContext(); if (prContext && prContext.baseBranch) { const candidates = [ `origin/${prContext.baseBranch}`, `upstream/${prContext.baseBranch}`, prContext.baseBranch ]; for (const candidate of candidates) { try { execSync(`git rev-parse --verify ${candidate}`, { cwd, stdio: 'ignore' }); return candidate; } catch (error) { // Continue to next candidate } } } // Fallback to common base branches const fallbackBranches = [ 'origin/main', 'origin/master', 'origin/develop', 'upstream/main', 'upstream/master', 'main', 'master', 'develop' ]; for (const branch of fallbackBranches) { try { execSync(`git rev-parse --verify ${branch}`, { cwd, stdio: 'ignore' }); return branch; } catch (error) { // Continue to next candidate } } // Last resort: use HEAD (uncommitted changes only) return 'HEAD'; } /** * Get list of changed files compared to base reference * @param {string|null} baseRef - Base git reference (e.g., 'origin/main'). If null, auto-detect. * @param {string} cwd - Working directory * @param {boolean} includeUncommitted - Include uncommitted changes * @returns {string[]} Array of changed file paths */ static getChangedFiles(baseRef = null, cwd = process.cwd(), includeUncommitted = true) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } try { // Get git root directory const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8' }).trim(); // Check if we're in PR context const prContext = this.detectPRContext(); // If in PR context and no explicit baseRef, try PR-specific logic if (prContext && !baseRef) { try { const prFiles = this.getPRChangedFiles(prContext, gitRoot); if (prFiles && prFiles.length >= 0) { return prFiles; } } catch (prError) { // Log warning and fallback to standard logic console.warn(`⚠️ PR context detected but failed to get changed files: ${prError.message}`); console.log(` Falling back to standard git diff logic...`); } } // Auto-detect base ref if not provided const actualBaseRef = baseRef || this.getSmartBaseRef(cwd); const allFiles = new Set(); // Get committed changes if (actualBaseRef !== 'HEAD') { // Use two-dot diff for branch comparison (what's new in this branch) const command = `git diff --name-only ${actualBaseRef}..HEAD`; const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' }); output .split('\n') .filter(file => file.trim() !== '') .forEach(file => allFiles.add(path.resolve(gitRoot, file))); } // Get uncommitted changes if requested if (includeUncommitted) { const uncommittedCommand = 'git diff --name-only HEAD'; try { const uncommittedOutput = execSync(uncommittedCommand, { cwd: gitRoot, encoding: 'utf8' }); uncommittedOutput .split('\n') .filter(file => file.trim() !== '') .forEach(file => allFiles.add(path.resolve(gitRoot, file))); } catch (error) { // Ignore errors for uncommitted changes (might be empty) } } // Filter to only existing files return Array.from(allFiles).filter(file => fs.existsSync(file)); } catch (error) { throw new Error(`Failed to get changed files: ${error.message}`); } } /** * Get changed files in PR context using merge-base * @param {Object} prContext - PR context info from detectPRContext() * @param {string} gitRoot - Git repository root path * @returns {string[]} Array of changed file paths in the PR */ static getPRChangedFiles(prContext, gitRoot) { try { const { baseBranch, provider } = prContext; console.log(`🔍 Detecting changed files for PR (provider: ${provider}, base: ${baseBranch})`); // Try to find the base branch reference let baseRef = this.findBaseRef(baseBranch, gitRoot); if (!baseRef) { console.log(`⚠️ Base ref not found locally, attempting to fetch origin/${baseBranch}...`); // Try to fetch and create the ref const fetchSuccess = this.ensureBaseRefExists(`origin/${baseBranch}`, gitRoot); if (fetchSuccess) { baseRef = `origin/${baseBranch}`; } else { throw new Error(`Cannot find or fetch base branch: ${baseBranch}`); } } else { // Ensure we have the latest console.log(`✅ Found base ref: ${baseRef}`); this.ensureBaseRefExists(baseRef, gitRoot); } // Use merge-base to find the common ancestor let mergeBase; try { mergeBase = execSync(`git merge-base ${baseRef} HEAD`, { cwd: gitRoot, encoding: 'utf8' }).trim(); console.log(`✅ Found merge-base: ${mergeBase.substring(0, 8)}`); } catch (error) { // If merge-base fails, fall back to direct comparison console.warn(`⚠️ Could not find merge-base, using direct diff with ${baseRef}`); mergeBase = baseRef; } // Get all files changed from merge-base to HEAD const command = `git diff --name-only ${mergeBase}...HEAD`; const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' }); const changedFiles = output .split('\n') .filter(file => file.trim() !== '') .map(file => path.resolve(gitRoot, file)) .filter(file => fs.existsSync(file)); console.log(`✅ Found ${changedFiles.length} changed files in PR`); return changedFiles; } catch (error) { throw new Error(`Failed to get PR changed files: ${error.message}`); } } /** * Find base reference for the given branch name * @param {string} baseBranch - Base branch name * @param {string} gitRoot - Git repository root path * @returns {string|null} Base reference or null if not found */ static findBaseRef(baseBranch, gitRoot) { const candidates = [ `origin/${baseBranch}`, `upstream/${baseBranch}`, baseBranch ]; for (const candidate of candidates) { try { execSync(`git rev-parse --verify ${candidate}`, { cwd: gitRoot, stdio: 'ignore' }); return candidate; } catch (error) { // Continue to next candidate } } return null; } /** * Ensure base ref exists (fetch if necessary) * @param {string} baseRef - Base reference * @param {string} gitRoot - Git repository root path */ static ensureBaseRefExists(baseRef, gitRoot) { try { // Check if ref exists execSync(`git rev-parse --verify ${baseRef}`, { cwd: gitRoot, stdio: 'ignore' }); // Ref exists, return true return true; } catch (error) { // Try to fetch if it doesn't exist const parts = baseRef.split('/'); const remote = parts[0]; const branch = parts.slice(1).join('/'); if (remote === 'origin' || remote === 'upstream') { try { console.log(`⬇️ Fetching ${remote}/${branch}...`); // Check if this is a shallow repository (common in GitHub Actions) const isShallow = this.isShallowRepository(gitRoot); if (isShallow) { console.log(` ℹ️ Detected shallow clone, fetching with additional history...`); // For shallow clones, we need to: // 1. Fetch the base branch // 2. Get enough history to find merge-base try { // Unshallow current branch first to get more history execSync(`git fetch --deepen=50`, { cwd: gitRoot, stdio: 'pipe', encoding: 'utf8' }); // Then fetch the base branch with history execSync(`git fetch ${remote} ${branch} --depth=50`, { cwd: gitRoot, stdio: 'pipe', encoding: 'utf8' }); } catch (shallowError) { // If deepen fails, try direct fetch console.log(` ℹ️ Trying direct fetch...`); execSync(`git fetch ${remote} ${branch}`, { cwd: gitRoot, stdio: 'pipe', encoding: 'utf8' }); } } else { // Normal fetch for non-shallow repos execSync(`git fetch ${remote} ${branch}`, { cwd: gitRoot, stdio: 'pipe', encoding: 'utf8' }); } // Verify it now exists execSync(`git rev-parse --verify ${baseRef}`, { cwd: gitRoot, stdio: 'ignore' }); console.log(`✅ Successfully fetched ${baseRef}`); return true; } catch (fetchError) { console.warn(`⚠️ Failed to fetch ${baseRef}: ${fetchError.message}`); return false; } } return false; } } /** * Check if repository is shallow * @param {string} gitRoot - Git repository root path * @returns {boolean} True if shallow */ static isShallowRepository(gitRoot) { try { const shallowFile = path.join(gitRoot, '.git', 'shallow'); return fs.existsSync(shallowFile); } catch (error) { return false; } } /** * Get list of staged files * @param {string} cwd - Working directory * @returns {string[]} Array of staged file paths */ static getStagedFiles(cwd = process.cwd()) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } try { // Get git root directory const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8' }).trim(); const command = 'git diff --cached --name-only'; const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' }); return output .split('\n') .filter(file => file.trim() !== '') .map(file => path.resolve(gitRoot, file)) .filter(file => fs.existsSync(file)); } catch (error) { throw new Error(`Failed to get staged files: ${error.message}`); } } /** * Get files changed since specific commit * @param {string} commit - Commit hash or reference * @param {string} cwd - Working directory * @returns {string[]} Array of changed file paths */ static getFilesSince(commit, cwd = process.cwd()) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } try { // Get git root directory const gitRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf8' }).trim(); const command = `git diff --name-only ${commit}..HEAD`; const output = execSync(command, { cwd: gitRoot, encoding: 'utf8' }); return output .split('\n') .filter(file => file.trim() !== '') .map(file => path.resolve(gitRoot, file)) .filter(file => fs.existsSync(file)); } catch (error) { throw new Error(`Failed to get files since ${commit}: ${error.message}`); } } /** * Get current branch name * @param {string} cwd - Working directory * @returns {string} Current branch name */ static getCurrentBranch(cwd = process.cwd()) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } try { const command = 'git rev-parse --abbrev-ref HEAD'; const output = execSync(command, { cwd, encoding: 'utf8' }); return output.trim(); } catch (error) { throw new Error(`Failed to get current branch: ${error.message}`); } } /** * Filter TypeScript/JavaScript files from file list * @param {string[]} files - Array of file paths * @returns {string[]} Filtered TypeScript/JavaScript files */ static filterSourceFiles(files) { const extensions = ['.ts', '.tsx', '.js', '.jsx']; return files.filter(file => { const ext = path.extname(file); return extensions.includes(ext); }); } /** * Get git diff base reference for PR mode * @param {string} targetBranch - Target branch (e.g., 'main', 'develop') * @param {string} cwd - Working directory * @returns {string} Git reference for comparison */ static getPRDiffBase(targetBranch = 'main', cwd = process.cwd()) { if (!this.isGitRepository(cwd)) { throw new Error('Not a git repository'); } // Try common remote references const candidates = [ `origin/${targetBranch}`, `upstream/${targetBranch}`, targetBranch ]; for (const candidate of candidates) { try { execSync(`git rev-parse --verify ${candidate}`, { cwd, stdio: 'ignore' }); return candidate; } catch (error) { // Continue to next candidate } } throw new Error(`No valid git reference found for target branch: ${targetBranch}`); } } module.exports = GitUtils;