UNPKG

git-bro

Version:

CLI tool that lets you download specific folders from GitHub repositories without cloning the entire repo.

481 lines (422 loc) 15.6 kB
const axios = require('axios'); const chalk = require('chalk'); const ora = require('ora'); const { table } = require('table'); /** * Fetches commits from a GitHub repository * @param {string} repo - Repository in format 'owner/repo' * @param {Object} options - Options for filtering commits * @returns {Promise<Array>} - Commits data */ const fetchCommits = async (repo, options) => { const spinner = ora(`Fetching commits for ${repo}`).start(); try { const [owner, repoName] = repo.split('/'); if (!owner || !repoName) { throw new Error('Invalid repository format. Use owner/repo format.'); } let url = `https://api.github.com/repos/${owner}/${repoName}/commits`; const params = { per_page: options.limit || 50 }; if (options.author) { params.author = options.author; } if (options.since) { params.since = options.since; } if (options.until) { params.until = options.until; } const response = await axios.get(url, { params }); spinner.succeed(`${response.data.length} commits fetched successfully`); // Fetch additional details for each commit if needed const commits = await Promise.all(response.data.map(async (commit) => { // If we need to filter by file or check for conflicts, fetch the commit details if (options.file || options.conflicts || options.showStats) { const detailsSpinner = ora(`Fetching details for ${commit.sha.substring(0, 7)}`).start(); try { const detailsResponse = await axios.get(commit.url); detailsSpinner.succeed(); return { ...commit, details: detailsResponse.data }; } catch (error) { detailsSpinner.fail(`Failed to fetch details for ${commit.sha.substring(0, 7)}`); return commit; } } return commit; })); return commits; } catch (error) { spinner.fail(`Failed to fetch commits: ${error.message}`); throw error; } }; /** * Filters commits based on options * @param {Array} commits - Commits data * @param {Object} options - Filter options * @returns {Array} - Filtered commits */ const filterCommits = (commits, options) => { let filteredCommits = commits; // Filter by file if (options.file) { filteredCommits = filteredCommits.filter(commit => { if (!commit.details || !commit.details.files) { return false; } return commit.details.files.some(file => file.filename.toLowerCase().includes(options.file.toLowerCase()) ); }); } // Filter by merge conflicts if (options.conflicts) { filteredCommits = filteredCommits.filter(commit => { if (!commit.details) { return false; } // Check if commit message mentions merge conflicts const message = commit.commit.message.toLowerCase(); return message.includes('conflict') || message.includes('resolve') || message.includes('merge') || message.includes('fix merge') || message.includes('resolve conflict'); }); } // Filter by commit type (feat, fix, docs, etc.) if (options.type) { filteredCommits = filteredCommits.filter(commit => { const message = commit.commit.message.toLowerCase(); return message.startsWith(options.type.toLowerCase()); }); } return filteredCommits; }; /** * Formats commit data for display * @param {Array} commits - Commits data * @returns {Array} - Formatted commit data */ const formatCommits = (commits) => { return commits.map(commit => { const date = new Date(commit.commit.author.date); const formattedDate = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const message = commit.commit.message.split('\n')[0]; // Get first line of commit message const author = commit.author ? commit.author.login : commit.commit.author.name; const authorEmail = commit.commit.author.email; // Calculate commit stats if available let stats = null; if (commit.details && commit.details.stats) { stats = { additions: commit.details.stats.additions || 0, deletions: commit.details.stats.deletions || 0, total: commit.details.stats.total || 0, filesChanged: commit.details.files ? commit.details.files.length : 0 }; } // Determine commit type from message let commitType = 'other'; const msg = message.toLowerCase(); if (msg.startsWith('feat') || msg.includes('feature')) commitType = 'feature'; else if (msg.startsWith('fix') || msg.includes('bug')) commitType = 'bugfix'; else if (msg.startsWith('docs') || msg.includes('documentation')) commitType = 'docs'; else if (msg.startsWith('refactor')) commitType = 'refactor'; else if (msg.startsWith('test')) commitType = 'test'; else if (msg.includes('merge')) commitType = 'merge'; return { sha: commit.sha.substring(0, 7), fullSha: commit.sha, date: formattedDate, time, author, authorEmail, message: message.length > 80 ? message.substring(0, 80) + '...' : message, fullMessage: commit.commit.message, stats, commitType, url: commit.html_url }; }); }; /** * Creates table configuration for better styling * @param {Array} data - Table data * @param {Object} options - Styling options * @returns {string} - Formatted table */ const createStyledTable = (data, options = {}) => { const config = { border: { topBody: '─', topJoin: '┬', topLeft: '┌', topRight: '┐', bottomBody: '─', bottomJoin: '┴', bottomLeft: '└', bottomRight: '┘', bodyLeft: '│', bodyRight: '│', bodyJoin: '│', joinBody: '─', joinLeft: '├', joinRight: '┤', joinJoin: '┼' }, columnDefault: { paddingLeft: 1, paddingRight: 1, }, ...options }; return table(data, config); }; /** * Gets emoji for commit type * @param {string} type - Commit type * @returns {string} - Emoji representation */ const getCommitTypeEmoji = (type) => { const emojis = { feature: '✨', bugfix: '🐛', docs: '📚', refactor: '♻️', test: '🧪', merge: '🔀', other: '💬' }; return emojis[type] || emojis.other; }; /** * Formats number with appropriate color coding * @param {number} num - Number to format * @param {string} type - Type (additions/deletions) * @returns {string} - Formatted and colored number */ const formatStatNumber = (num, type) => { const formatted = num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); if (type === 'additions') return chalk.green(`+${formatted}`); if (type === 'deletions') return chalk.red(`-${formatted}`); return chalk.white(formatted); }; /** * Builds a tree representation of commits with enhanced styling * @param {Array} commits - Formatted commit data * @returns {string} - Tree representation */ const buildCommitTree = (commits) => { let tree = ''; commits.forEach((commit, index) => { const isLast = index === commits.length - 1; const prefix = isLast ? '└── ' : '├── '; const emoji = getCommitTypeEmoji(commit.commitType); let line = `${chalk.gray(prefix)}${emoji} ${chalk.yellow(commit.sha)} `; line += `${chalk.cyan(commit.date)} ${chalk.gray(commit.time)} `; line += `${chalk.blue('@' + commit.author)} `; if (commit.stats) { line += `${chalk.gray('[')}${formatStatNumber(commit.stats.additions, 'additions')}${chalk.gray('/')}${formatStatNumber(commit.stats.deletions, 'deletions')}${chalk.gray(']')} `; } line += `${chalk.white(commit.message)}`; tree += line + '\n'; }); return tree; }; /** * Generates commit statistics summary * @param {Array} commits - Formatted commit data * @returns {Object} - Statistics summary */ const generateCommitStats = (commits) => { const stats = { totalCommits: commits.length, authors: new Set(), commitTypes: {}, totalAdditions: 0, totalDeletions: 0, totalFilesChanged: 0, dateRange: { earliest: null, latest: null } }; commits.forEach(commit => { stats.authors.add(commit.author); if (!stats.commitTypes[commit.commitType]) { stats.commitTypes[commit.commitType] = 0; } stats.commitTypes[commit.commitType]++; if (commit.stats) { stats.totalAdditions += commit.stats.additions; stats.totalDeletions += commit.stats.deletions; stats.totalFilesChanged += commit.stats.filesChanged; } const commitDate = new Date(commit.date + ' ' + commit.time); if (!stats.dateRange.earliest || commitDate < stats.dateRange.earliest) { stats.dateRange.earliest = commitDate; } if (!stats.dateRange.latest || commitDate > stats.dateRange.latest) { stats.dateRange.latest = commitDate; } }); stats.uniqueAuthors = stats.authors.size; return stats; }; /** * Explores commit history of a GitHub repository * @param {string} repo - Repository in format 'owner/repo' * @param {Object} options - Options for exploring commits * @returns {Promise<void>} */ const exploreCommitHistory = async (repo, options = {}) => { console.log(chalk.blue.bold(`\n🔍 Exploring commit history for ${chalk.yellow(repo)}\n`)); console.log(chalk.gray('━'.repeat(80))); try { // Fetch commits const commits = await fetchCommits(repo, options); // Filter commits based on options const filteredCommits = filterCommits(commits, options); if (filteredCommits.length === 0) { console.log(chalk.yellow('\n⚠️ No commits found matching the specified criteria.')); return; } // Format commits for display const formattedCommits = formatCommits(filteredCommits); // Generate statistics const stats = generateCommitStats(formattedCommits); // Display applied filters console.log(chalk.green.bold('\n🔧 Filters Applied')); console.log(chalk.gray('━'.repeat(40))); const filtersData = [ [chalk.bold('Filter'), chalk.bold('Value')], ['Repository', chalk.cyan(repo)], ['Limit', chalk.white(options.limit || 50)], ['Author', options.author ? chalk.blue(options.author) : chalk.gray('All authors')], ['File Filter', options.file ? chalk.yellow(options.file) : chalk.gray('All files')], ['Commit Type', options.type ? chalk.green(options.type) : chalk.gray('All types')], ['Merge Conflicts Only', options.conflicts ? chalk.red('Yes') : chalk.gray('No')], ['Date Range', options.since || options.until ? `${options.since || 'Beginning'} - ${options.until || 'Latest'}` : chalk.gray('All time')] ]; const filterTableConfig = { columns: { 0: { width: 20, alignment: 'right' }, 1: { width: 40, alignment: 'left' } } }; console.log(createStyledTable(filtersData, filterTableConfig)); // Display commit statistics console.log(chalk.green.bold('\n📊 Commit Statistics')); console.log(chalk.gray('━'.repeat(40))); const statsData = [ [chalk.bold('Metric'), chalk.bold('Value')], ['Total Commits', chalk.cyan(stats.totalCommits.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','))], ['Unique Authors', chalk.blue(stats.uniqueAuthors)], ['Total Additions', formatStatNumber(stats.totalAdditions, 'additions')], ['Total Deletions', formatStatNumber(stats.totalDeletions, 'deletions')], ['Files Changed', chalk.white(stats.totalFilesChanged.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','))], ['Date Range', stats.dateRange.earliest && stats.dateRange.latest ? `${stats.dateRange.earliest.toLocaleDateString()} - ${stats.dateRange.latest.toLocaleDateString()}` : chalk.gray('N/A')] ]; console.log(createStyledTable(statsData, filterTableConfig)); // Display commit type breakdown console.log(chalk.green.bold('\n📈 Commit Type Breakdown')); console.log(chalk.gray('━'.repeat(40))); const typeData = [ [chalk.bold('Type'), chalk.bold('Count'), chalk.bold('Percentage')] ]; Object.entries(stats.commitTypes) .sort(([,a], [,b]) => b - a) .forEach(([type, count]) => { const percentage = ((count / stats.totalCommits) * 100).toFixed(1); const emoji = getCommitTypeEmoji(type); typeData.push([ `${emoji} ${chalk.cyan(type)}`, chalk.white(count), chalk.yellow(`${percentage}%`) ]); }); const typeTableConfig = { columns: { 0: { width: 15, alignment: 'left' }, 1: { width: 10, alignment: 'center' }, 2: { width: 15, alignment: 'center' } } }; console.log(createStyledTable(typeData, typeTableConfig)); // Display commits in detailed table format console.log(chalk.green.bold('\n📋 Commit Details')); console.log(chalk.gray('━'.repeat(80))); const tableData = [ [ chalk.bold('SHA'), chalk.bold('Date'), chalk.bold('Author'), chalk.bold('Type'), chalk.bold('Changes'), chalk.bold('Message') ] ]; formattedCommits.forEach(commit => { const emoji = getCommitTypeEmoji(commit.commitType); const changes = commit.stats ? `${formatStatNumber(commit.stats.additions, 'additions')}/${formatStatNumber(commit.stats.deletions, 'deletions')}` : chalk.gray('N/A'); tableData.push([ chalk.yellow(commit.sha), chalk.cyan(commit.date), chalk.blue(commit.author), `${emoji} ${commit.commitType}`, changes, chalk.white(commit.message) ]); }); const commitTableConfig = { columns: { 0: { width: 8, alignment: 'center' }, 1: { width: 12, alignment: 'center' }, 2: { width: 15, alignment: 'left' }, 3: { width: 12, alignment: 'center' }, 4: { width: 15, alignment: 'center' }, 5: { width: 50, alignment: 'left', wrapWord: true } } }; console.log(createStyledTable(tableData, commitTableConfig)); // Display commit tree console.log(chalk.green.bold('\n🌳 Commit Tree Visualization')); console.log(chalk.gray('━'.repeat(80))); console.log(buildCommitTree(formattedCommits)); // Display summary console.log(chalk.blue.bold('\n✅ Commit History Analysis Complete')); console.log(chalk.gray('━'.repeat(80))); console.log(chalk.blue(`📊 Repository: ${chalk.yellow(repo)}`)); console.log(chalk.blue(`📈 Analyzed: ${chalk.cyan(stats.totalCommits)} commits by ${chalk.blue(stats.uniqueAuthors)} authors`)); console.log(chalk.blue(`🗓️ Period: ${stats.dateRange.earliest ? `${stats.dateRange.earliest.toLocaleDateString()} to ${stats.dateRange.latest.toLocaleDateString()}` : 'Full history'}`)); console.log(chalk.blue(`⏰ Generated at: ${chalk.white(new Date().toLocaleString())}`)); } catch (error) { console.error(chalk.red(`\n❌ Error exploring commit history: ${error.message}`)); console.error(chalk.gray('━'.repeat(80))); throw error; } }; module.exports = { exploreCommitHistory };