reviewit
Version:
A lightweight command-line tool that spins up a local web server to display Git commit diffs in a GitHub-like Files changed view
195 lines (194 loc) • 7.33 kB
JavaScript
import { execSync } from 'child_process';
import { createInterface } from 'readline/promises';
import { Octokit } from '@octokit/rest';
export function validateCommitish(commitish) {
if (!commitish || typeof commitish !== 'string') {
return false;
}
const trimmed = commitish.trim();
if (trimmed.length === 0) {
return false;
}
// Special cases
if (trimmed === 'HEAD~') {
return false;
}
if (trimmed === '.' || trimmed === 'working' || trimmed === 'staged') {
return true; // Allow special keywords for working directory and staging area diff
}
const validPatterns = [
/^[a-f0-9]{4,40}$/i, // SHA hashes
/^[a-f0-9]{4,40}\^+$/i, // SHA hashes with ^ suffix (parent references)
/^[a-f0-9]{4,40}~\d+$/i, // SHA hashes with ~N suffix (ancestor references)
/^HEAD(~\d+|\^\d*)*$/, // HEAD, HEAD~1, HEAD^, HEAD^2, etc.
/^[a-zA-Z][a-zA-Z0-9_\-/.]*$/, // branch names, tags (must start with letter, no ^ or ~ in middle)
];
return validPatterns.some((pattern) => pattern.test(trimmed));
}
export function shortHash(hash) {
return hash.substring(0, 7);
}
export function createCommitRangeString(baseHash, targetHash) {
return `${baseHash}...${targetHash}`;
}
export function parseGitHubPrUrl(url) {
try {
const urlObj = new URL(url);
if (urlObj.hostname !== 'github.com') {
return null;
}
const pathParts = urlObj.pathname.split('/').filter(Boolean);
if (pathParts.length < 4 || pathParts[2] !== 'pull') {
return null;
}
const owner = pathParts[0];
const repo = pathParts[1];
const pullNumber = parseInt(pathParts[3], 10);
if (isNaN(pullNumber)) {
return null;
}
return { owner, repo, pullNumber };
}
catch {
return null;
}
}
function getGitHubToken() {
// Try to get token from environment variable first
if (process.env.GITHUB_TOKEN) {
return process.env.GITHUB_TOKEN;
}
// Try to get token from GitHub CLI
try {
const result = execSync('gh auth token', { encoding: 'utf8', stdio: 'pipe' });
return result.trim();
}
catch {
// GitHub CLI not available or not authenticated
return undefined;
}
}
export async function fetchPrDetails(prInfo) {
const token = getGitHubToken();
const octokit = new Octokit({
auth: token,
});
try {
const { data: pr } = await octokit.rest.pulls.get({
owner: prInfo.owner,
repo: prInfo.repo,
pull_number: prInfo.pullNumber,
});
return {
baseSha: pr.base.sha,
headSha: pr.head.sha,
baseRef: pr.base.ref,
headRef: pr.head.ref,
};
}
catch (error) {
if (error instanceof Error) {
const authHint = token
? ''
: ' (Try: gh auth login or set GITHUB_TOKEN environment variable)';
throw new Error(`Failed to fetch PR details: ${error.message}${authHint}`);
}
throw new Error('Failed to fetch PR details: Unknown error');
}
}
export function resolveCommitInLocalRepo(sha, context) {
try {
// Verify if the commit exists locally
execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
return sha;
}
catch {
// If commit doesn't exist, try to fetch from remote
try {
execSync('git fetch origin', { stdio: 'ignore' });
execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
return sha;
}
catch {
const errorMessage = [
`Commit ${sha} not found in local repository.`,
'',
'Common causes:',
' • Are you running this command in the correct repository directory?',
context ? ` • Expected repository: ${context.owner}/${context.repo}` : '',
' • Is this PR from a fork?',
' • Try: git remote add upstream <original-repo-url> && git fetch upstream',
' • Try: git fetch --all to fetch from all remotes',
]
.filter(Boolean)
.join('\n');
throw new Error(errorMessage);
}
}
}
export async function resolvePrCommits(prUrl) {
const prInfo = parseGitHubPrUrl(prUrl);
if (!prInfo) {
throw new Error('Invalid GitHub PR URL format. Expected: https://github.com/owner/repo/pull/123');
}
const prDetails = await fetchPrDetails(prInfo);
const context = { owner: prInfo.owner, repo: prInfo.repo };
const targetCommitish = resolveCommitInLocalRepo(prDetails.headSha, context);
const baseCommitish = resolveCommitInLocalRepo(prDetails.baseSha, context);
return { targetCommitish, baseCommitish };
}
export function validateDiffArguments(targetCommitish, baseCommitish) {
// Validate target commitish format
if (!validateCommitish(targetCommitish)) {
return { valid: false, error: 'Invalid target commit-ish format' };
}
// Validate base commitish format if provided
if (baseCommitish !== undefined && !validateCommitish(baseCommitish)) {
return { valid: false, error: 'Invalid base commit-ish format' };
}
// Special arguments are only allowed in target, not base (except staged with working)
const specialArgs = ['working', 'staged', '.'];
if (baseCommitish && specialArgs.includes(baseCommitish)) {
// Allow 'staged' as base only when target is 'working'
if (baseCommitish === 'staged' && targetCommitish === 'working') {
// This is valid: working vs staged
}
else {
return {
valid: false,
error: `Special arguments (working, staged, .) are only allowed as target, not base. Got base: ${baseCommitish}`,
};
}
}
// Cannot compare same values
if (targetCommitish === baseCommitish) {
return { valid: false, error: `Cannot compare ${targetCommitish} with itself` };
}
// "working" shows unstaged changes and can only be compared with staging area
if (targetCommitish === 'working' && baseCommitish && baseCommitish !== 'staged') {
return {
valid: false,
error: '"working" shows unstaged changes and cannot be compared with another commit. Use "." instead to compare all uncommitted changes with a specific commit.',
};
}
return { valid: true };
}
export async function findUntrackedFiles(git) {
const status = await git.status();
return status.not_added;
}
// Add files with --intent-to-add to make them visible in `git diff` without staging content
export async function markFilesIntentToAdd(git, files) {
await git.add(['--intent-to-add', ...files]);
}
export async function promptUser(message) {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await rl.question(message);
rl.close();
// Empty string (Enter) or 'y', 'yes' return true
const trimmed = answer.trim().toLowerCase();
return trimmed === '' || ['y', 'yes'].includes(trimmed);
}