@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
JavaScript
/**
* 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;