n8n/packages/testing/janitor/src/utils/git-operations.ts
Declan Carroll 7be48a4114
fix: Fix selector-purity violation and E2E impact analysis in CI (#26410)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:56:48 +00:00

156 lines
4.2 KiB
TypeScript

/**
* Git Operations Utility
*
* Centralized git operations for the janitor tool.
*/
import { execFileSync } from 'node:child_process';
import * as path from 'node:path';
export interface GitChangedFilesOptions {
targetBranch?: string;
scopeDir: string;
extensions?: string[];
}
export function getGitRoot(cwd: string): string {
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf-8',
}).trim();
} catch {
return cwd;
}
}
export function parseGitStatus(
output: string,
gitRoot: string,
scopeDir: string,
extensions: string[] = ['.ts'],
): string[] {
const files: string[] = [];
const gitStatusPattern = /^.{2}\s+(.+)$/;
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const match = gitStatusPattern.exec(line);
if (match) {
const filePath = match[1];
// Handle renamed files (old -> new)
const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
const fullPath = path.join(gitRoot, actualPath);
// Filter by scope directory
if (!fullPath.startsWith(scopeDir)) continue;
// Filter by extension if specified
if (extensions.length > 0) {
const hasValidExtension = extensions.some((ext) => actualPath.endsWith(ext));
if (!hasValidExtension) continue;
}
files.push(fullPath);
}
}
return files;
}
export function parseGitDiff(output: string, gitRoot: string, scopeDir: string): string[] {
const files: string[] = [];
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const fullPath = path.join(gitRoot, line);
if (fullPath.startsWith(scopeDir)) {
files.push(fullPath);
}
}
return files;
}
export function getChangedFiles(options: GitChangedFilesOptions): string[] {
const { targetBranch, scopeDir, extensions = [] } = options;
const gitRoot = getGitRoot(scopeDir);
try {
if (targetBranch) {
// Fetch the base branch at depth=1 so FETCH_HEAD is available for the diff.
// Using FETCH_HEAD with two-dot diff avoids the need for a merge base, which
// fails with shallow clones (fatal: no merge base). This matches the approach
// used by the ci-filter action across the rest of the repo.
const remoteName = targetBranch.startsWith('origin/') ? targetBranch.slice(7) : targetBranch;
execFileSync('git', ['fetch', '--depth=1', 'origin', remoteName], {
cwd: gitRoot,
encoding: 'utf-8',
stdio: 'pipe',
});
const output = execFileSync('git', ['diff', '--name-only', 'FETCH_HEAD', 'HEAD'], {
cwd: gitRoot,
encoding: 'utf-8',
});
return parseGitDiff(output, gitRoot, scopeDir);
}
// Use -uall to show individual files in new directories instead of just "dir/"
const status = execFileSync('git', ['status', '--porcelain', '-uall'], {
cwd: gitRoot,
encoding: 'utf-8',
});
return parseGitStatus(status, gitRoot, scopeDir, extensions);
} catch {
return [];
}
}
export function getTotalDiffLines(scopeDir: string, targetBranch?: string): number {
const gitRoot = getGitRoot(scopeDir);
try {
const args = targetBranch
? ['diff', '--stat', `${targetBranch}...HEAD`]
: ['diff', '--stat', 'HEAD'];
const output = execFileSync('git', args, { cwd: gitRoot, encoding: 'utf-8' });
const lines = output.trim().split('\n');
const summaryLine = lines[lines.length - 1];
let total = 0;
const insertionsPattern = /(\d+)\s+insertions?\(\+\)/;
const deletionsPattern = /(\d+)\s+deletions?\(-\)/;
const insertionsMatch = insertionsPattern.exec(summaryLine);
const deletionsMatch = deletionsPattern.exec(summaryLine);
if (insertionsMatch) total += Number.parseInt(insertionsMatch[1], 10);
if (deletionsMatch) total += Number.parseInt(deletionsMatch[1], 10);
return total;
} catch {
return 0;
}
}
export function commit(message: string, cwd: string): boolean {
const gitRoot = getGitRoot(cwd);
try {
execFileSync('git', ['add', '-A'], { cwd: gitRoot });
execFileSync('git', ['commit', '-m', message], { cwd: gitRoot });
return true;
} catch {
return false;
}
}
export function revert(cwd: string): boolean {
const gitRoot = getGitRoot(cwd);
try {
execFileSync('git', ['checkout', '--', '.'], { cwd: gitRoot });
return true;
} catch {
return false;
}
}