@zubenelakrab/gitstats
Version:
Powerful Git repository analyzer with comprehensive statistics and insights
323 lines • 16 kB
JavaScript
import chalk from 'chalk';
import Table from 'cli-table3';
import { formatDate, getRelativeTime } from '../utils/date.js';
/**
* CLI output renderer with colored tables
*/
export class CliRenderer {
async render(report, _config) {
const sections = [];
// Header
sections.push(this.renderHeader(report));
// Summary
sections.push(this.renderSummary(report));
// Top Authors
sections.push(this.renderTopAuthors(report.authors.slice(0, 10)));
// Hotspots
sections.push(this.renderHotspots(report));
// Bus Factor
sections.push(this.renderBusFactor(report));
// Activity Heatmap
sections.push(this.renderActivityHeatmap(report));
// Velocity (if available)
if (report.velocity) {
sections.push(this.renderVelocity(report));
}
// Work Patterns (if available)
if (report.workPatterns) {
sections.push(this.renderWorkPatterns(report));
}
// Commit Quality (if available)
if (report.commitQuality) {
sections.push(this.renderCommitQuality(report));
}
// Health (if available)
if (report.health) {
sections.push(this.renderHealth(report));
}
// Collaboration (if available)
if (report.collaboration) {
sections.push(this.renderCollaboration(report));
}
// Branch Analysis (if available)
if (report.branchAnalysis) {
sections.push(this.renderBranches(report));
}
return sections.join('\n\n');
}
async save(content, path) {
const { writeFile } = await import('node:fs/promises');
// Strip ANSI codes for file output
const stripped = content.replace(/\x1b\[[0-9;]*m/g, '');
await writeFile(path, stripped, 'utf-8');
}
renderHeader(report) {
const lines = [
chalk.bold.cyan('═══════════════════════════════════════════════════════════'),
chalk.bold.cyan(` GitStats Report: ${report.repository.name}`),
chalk.bold.cyan('═══════════════════════════════════════════════════════════'),
'',
chalk.gray(` Generated: ${formatDate(report.generatedAt)}`),
chalk.gray(` Repository: ${report.repository.path}`),
];
return lines.join('\n');
}
renderSummary(report) {
const { summary } = report;
const table = new Table({
head: [chalk.bold('Metric'), chalk.bold('Value')],
style: { head: [], border: [] },
});
table.push(['Total Commits', chalk.green(summary.totalCommits.toLocaleString())], ['Total Authors', chalk.blue(summary.totalAuthors.toString())], ['Total Files Changed', summary.totalFiles.toLocaleString()], ['Lines Added', chalk.green(`+${summary.totalAdditions.toLocaleString()}`)], ['Lines Deleted', chalk.red(`-${summary.totalDeletions.toLocaleString()}`)], ['Repository Age', `${summary.repositoryAge} days`], ['Avg Commits/Day', summary.averageCommitsPerDay.toFixed(2)], ['Most Active Author', summary.mostActiveAuthor.name], ['Most Changed File', this.truncatePath(summary.mostChangedFile, 40)]);
return chalk.bold('\n📊 Summary\n') + table.toString();
}
renderTopAuthors(authors) {
const table = new Table({
head: [
chalk.bold('#'),
chalk.bold('Author'),
chalk.bold('Commits'),
chalk.bold('Additions'),
chalk.bold('Deletions'),
chalk.bold('Files'),
chalk.bold('Last Active'),
],
style: { head: [], border: [] },
});
authors.forEach((author, index) => {
table.push([
(index + 1).toString(),
this.truncate(author.author.name, 20),
chalk.yellow(author.commits.toString()),
chalk.green(`+${author.additions.toLocaleString()}`),
chalk.red(`-${author.deletions.toLocaleString()}`),
author.filesChanged.toString(),
getRelativeTime(author.lastCommit),
]);
});
return chalk.bold('\n👥 Top Contributors\n') + table.toString();
}
renderHotspots(report) {
const hotFiles = report.hotspots.files.slice(0, 10);
const table = new Table({
head: [
chalk.bold('#'),
chalk.bold('File'),
chalk.bold('Commits'),
chalk.bold('Churn'),
chalk.bold('Authors'),
],
style: { head: [], border: [] },
});
hotFiles.forEach((file, index) => {
const churnColor = file.churnScore > 100 ? chalk.red :
file.churnScore > 50 ? chalk.yellow : chalk.green;
table.push([
(index + 1).toString(),
this.truncatePath(file.path, 45),
file.commits.toString(),
churnColor(file.churnScore.toFixed(1)),
file.authors.length.toString(),
]);
});
return chalk.bold('\n🔥 Code Hotspots (High Churn Files)\n') + table.toString();
}
renderBusFactor(report) {
const { busFactor } = report;
const lines = [];
// Overall bus factor with visual indicator
const overallColor = busFactor.overall <= 1 ? chalk.red :
busFactor.overall <= 2 ? chalk.yellow : chalk.green;
const riskEmoji = busFactor.overall <= 1 ? '🚨' :
busFactor.overall <= 2 ? '⚠️' : '✅';
lines.push(chalk.bold('\n🚌 Bus Factor Analysis\n'));
lines.push(` Overall Bus Factor: ${overallColor.bold(busFactor.overall.toString())} ${riskEmoji}`);
lines.push('');
// Critical areas
if (busFactor.criticalAreas.length > 0) {
lines.push(chalk.bold(' Critical Areas (High Risk):'));
const criticalTable = new Table({
head: [chalk.bold('File'), chalk.bold('Risk'), chalk.bold('Sole Owner')],
style: { head: [], border: [] },
});
const topCritical = busFactor.criticalAreas
.filter(a => a.risk === 'high')
.slice(0, 5);
for (const area of topCritical) {
const riskBadge = area.risk === 'high' ? chalk.bgRed.white(' HIGH ') :
chalk.bgYellow.black(' MED ');
criticalTable.push([
this.truncatePath(area.path, 40),
riskBadge,
area.soleContributor?.name || '-',
]);
}
lines.push(criticalTable.toString());
}
else {
lines.push(chalk.green(' ✅ No critical risk areas detected'));
}
return lines.join('\n');
}
renderActivityHeatmap(report) {
const lines = [];
lines.push(chalk.bold('\n📅 Activity by Day of Week\n'));
// Aggregate commits by day of week across all authors
const dayTotals = new Array(7).fill(0);
for (const author of report.authors) {
for (let i = 0; i < 7; i++) {
dayTotals[i] += author.commitsByDayOfWeek[i];
}
}
const maxDay = Math.max(...dayTotals);
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
for (let i = 0; i < 7; i++) {
const ratio = maxDay > 0 ? dayTotals[i] / maxDay : 0;
const barLength = Math.round(ratio * 30);
const bar = '█'.repeat(barLength) + '░'.repeat(30 - barLength);
const coloredBar = ratio > 0.7 ? chalk.green(bar) :
ratio > 0.3 ? chalk.yellow(bar) : chalk.gray(bar);
lines.push(` ${days[i]} ${coloredBar} ${dayTotals[i]}`);
}
// Hour distribution
lines.push(chalk.bold('\n⏰ Activity by Hour\n'));
const hourTotals = new Array(24).fill(0);
for (const author of report.authors) {
for (let i = 0; i < 24; i++) {
hourTotals[i] += author.commitsByHour[i];
}
}
const maxHour = Math.max(...hourTotals);
const hourGroups = [
{ label: 'Night (00-06)', hours: [0, 1, 2, 3, 4, 5] },
{ label: 'Morning (06-12)', hours: [6, 7, 8, 9, 10, 11] },
{ label: 'Afternoon (12-18)', hours: [12, 13, 14, 15, 16, 17] },
{ label: 'Evening (18-24)', hours: [18, 19, 20, 21, 22, 23] },
];
for (const group of hourGroups) {
const total = group.hours.reduce((sum, h) => sum + hourTotals[h], 0);
const ratio = maxHour > 0 ? (total / group.hours.length) / maxHour : 0;
const barLength = Math.round(ratio * 25);
const bar = '█'.repeat(barLength) + '░'.repeat(25 - barLength);
lines.push(` ${group.label.padEnd(18)} ${chalk.cyan(bar)} ${total}`);
}
return lines.join('\n');
}
renderVelocity(report) {
const velocity = report.velocity;
const lines = [];
lines.push(chalk.bold('\n🚀 Development Velocity\n'));
const trendColor = velocity.trend === 'accelerating' ? chalk.green :
velocity.trend === 'decelerating' ? chalk.red : chalk.yellow;
const trendEmoji = velocity.trend === 'accelerating' ? '📈' :
velocity.trend === 'decelerating' ? '📉' : '➡️';
const table = new Table({
style: { head: [], border: [] },
});
table.push(['Commits/Day', velocity.commitsPerDay.toFixed(2)], ['Commits/Week', velocity.commitsPerWeek.toFixed(2)], ['Commits/Month', velocity.commitsPerMonth.toFixed(2)], ['Trend', `${trendColor(velocity.trend)} ${trendEmoji} (${velocity.trendPercentage > 0 ? '+' : ''}${velocity.trendPercentage.toFixed(1)}%)`], ['Consistency', `${velocity.consistencyScore.toFixed(0)}%`], ['Avg Time Between Commits', `${velocity.averageTimeBetweenCommits.toFixed(1)} hours`]);
lines.push(table.toString());
return lines.join('\n');
}
renderWorkPatterns(report) {
const patterns = report.workPatterns;
const lines = [];
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
lines.push(chalk.bold('\n⏰ Work Patterns\n'));
const balanceColor = patterns.workLifeBalance >= 70 ? chalk.green :
patterns.workLifeBalance >= 40 ? chalk.yellow : chalk.red;
const table = new Table({
style: { head: [], border: [] },
});
table.push(['Peak Hour', `${patterns.peakHour.toString().padStart(2, '0')}:00`], ['Peak Day', days[patterns.peakDay]], ['Night Owl %', `${patterns.nightOwlPercentage.toFixed(1)}%`], ['Weekend %', `${patterns.weekendPercentage.toFixed(1)}%`], ['Work-Life Balance', balanceColor(`${patterns.workLifeBalance.toFixed(0)}/100`)]);
lines.push(table.toString());
if (patterns.crunchPeriods.length > 0) {
lines.push(chalk.bold.red(`\n 🔥 Crunch Periods: ${patterns.crunchPeriods.length}`));
}
return lines.join('\n');
}
renderCommitQuality(report) {
const quality = report.commitQuality;
const lines = [];
lines.push(chalk.bold('\n📝 Commit Quality\n'));
const scoreColor = quality.qualityScore >= 80 ? chalk.green :
quality.qualityScore >= 60 ? chalk.yellow : chalk.red;
const table = new Table({
style: { head: [], border: [] },
});
table.push(['Quality Score', scoreColor(`${quality.qualityScore.toFixed(0)}/100`)], ['Atomic Score', `${quality.atomicCommitScore.toFixed(0)}/100`], ['Conventional Commits', `${quality.conventionalPercentage.toFixed(1)}%`], ['Fix/Bugfix Commits', `${quality.fixPercentage.toFixed(1)}%`], ['WIP Commits', quality.wipCommits.length.toString()], ['Large Commits', quality.largeCommits.length.toString()]);
lines.push(table.toString());
return lines.join('\n');
}
renderHealth(report) {
const health = report.health;
const lines = [];
lines.push(chalk.bold('\n🏥 Repository Health\n'));
const scoreColor = health.healthScore >= 80 ? chalk.green :
health.healthScore >= 60 ? chalk.yellow : chalk.red;
const emoji = health.healthScore >= 80 ? '💚' :
health.healthScore >= 60 ? '💛' : '❤️';
lines.push(` Health Score: ${scoreColor(health.healthScore.toString())} ${emoji}`);
lines.push('');
for (const indicator of health.indicators) {
const statusEmoji = indicator.status === 'good' ? '✅' :
indicator.status === 'warning' ? '⚠️' : '❌';
const statusColor = indicator.status === 'good' ? chalk.green :
indicator.status === 'warning' ? chalk.yellow : chalk.red;
lines.push(` ${statusEmoji} ${indicator.name.padEnd(18)} ${statusColor(indicator.value)}`);
}
if (health.zombieFiles.length > 0) {
lines.push(chalk.gray(`\n 🧟 ${health.zombieFiles.length} zombie files detected`));
}
if (health.abandonedDirs.length > 0) {
lines.push(chalk.gray(` 📂 ${health.abandonedDirs.length} abandoned directories`));
}
return lines.join('\n');
}
renderCollaboration(report) {
const collab = report.collaboration;
const lines = [];
lines.push(chalk.bold('\n🤝 Collaboration\n'));
const scoreColor = collab.collaborationScore >= 70 ? chalk.green :
collab.collaborationScore >= 40 ? chalk.yellow : chalk.red;
lines.push(` Collaboration Score: ${scoreColor(collab.collaborationScore.toFixed(0) + '/100')}`);
lines.push(` Top Collaborating Pairs: ${collab.collaborationPairs.length}`);
lines.push(` Most Shared Files: ${collab.sharedFiles.length}`);
if (collab.loneWolves.length > 0) {
lines.push(chalk.yellow(` 🐺 Lone Wolves: ${collab.loneWolves.length}`));
}
return lines.join('\n');
}
renderBranches(report) {
const branches = report.branchAnalysis;
const lines = [];
lines.push(chalk.bold('\n🌿 Branch Analysis\n'));
const scoreColor = branches.branchHealthScore >= 80 ? chalk.green :
branches.branchHealthScore >= 60 ? chalk.yellow : chalk.red;
const table = new Table({
style: { head: [], border: [] },
});
table.push(['Branch Health', scoreColor(`${branches.branchHealthScore}/100`)], ['Total Branches', branches.totalBranches.toString()], ['Average Age', `${branches.averageBranchAge} days`], ['Stale Branches', chalk.yellow(branches.staleBranches.length.toString())], ['Orphan Branches', chalk.red(branches.orphanBranches.length.toString())]);
lines.push(table.toString());
return lines.join('\n');
}
truncate(str, maxLength) {
if (str.length <= maxLength)
return str;
return str.substring(0, maxLength - 3) + '...';
}
truncatePath(path, maxLength) {
if (path.length <= maxLength)
return path;
const parts = path.split('/');
if (parts.length <= 2)
return this.truncate(path, maxLength);
// Keep first and last parts, truncate middle
const first = parts[0];
const last = parts.slice(-2).join('/');
return `${first}/.../${last}`.substring(0, maxLength);
}
}
export function createCliRenderer() {
return new CliRenderer();
}
//# sourceMappingURL=cli-renderer.js.map