UNPKG

git-kloc-analyzer

Version:

A powerful TypeScript tool for analyzing KLOC (Kilo Lines of Code) statistics from Git repositories with email-based contributor grouping

373 lines 16.6 kB
#!/usr/bin/env node "use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.GitKlocAnalyzer = void 0; const commander_1 = require("commander"); const child_process_1 = require("child_process"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); /** * Analyzer for calculating KLOC (Kilo Lines of Code) statistics from Git repositories */ class GitKlocAnalyzer { /** * Creates a new GitKlocAnalyzer instance * @param repoPath - Path to the Git repository * @param fromDate - Start date in YYYY-MM-DD format * @param toDate - End date in YYYY-MM-DD format */ constructor(repoPath, fromDate, toDate) { this.repoPath = repoPath; this.fromDate = fromDate; this.toDate = toDate; } executeGitCommand(command) { try { const result = (0, child_process_1.execSync)(command, { cwd: this.repoPath, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], maxBuffer: 1024 * 1024 * 50, // 50MB buffer for large repositories }); return result.toString().trim(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Git command failed: ${command}\nError: ${errorMessage}`); } } validateRepository() { if (!fs.existsSync(this.repoPath)) { throw new Error(`Repository path does not exist: ${this.repoPath}`); } if (!fs.existsSync(path.join(this.repoPath, ".git"))) { throw new Error(`Not a git repository: ${this.repoPath}`); } } validateDateFormat(date) { const regex = /^\d{4}-\d{2}-\d{2}$/; return regex.test(date) && !isNaN(Date.parse(date)); } validateDateRange() { if (!this.validateDateFormat(this.fromDate)) { throw new Error(`Invalid from date format: ${this.fromDate}. Expected YYYY-MM-DD`); } if (!this.validateDateFormat(this.toDate)) { throw new Error(`Invalid to date format: ${this.toDate}. Expected YYYY-MM-DD`); } if (new Date(this.fromDate) > new Date(this.toDate)) { throw new Error(`From date (${this.fromDate}) must be before to date (${this.toDate})`); } } getCommitsInRange() { const gitLogCommand = `git log --since="${this.fromDate}" --until="${this.toDate}" --pretty=format:"%H|%an|%ae|%ad|%s" --date=short`; const output = this.executeGitCommand(gitLogCommand); if (!output) { return []; } return output .split("\n") .map((line) => { const parts = line.split("|"); if (parts.length < 5) { console.warn(`Warning: Invalid commit line format: ${line}`); return null; } const [hash, author, email, date, subject] = parts; return { hash, author, email, date, subject }; }) .filter((commit) => commit !== null); } getAllCommitStatsOptimized() { try { const statsCommand = `git log --since="${this.fromDate}" --until="${this.toDate}" --numstat --pretty=format:"%H"`; const output = this.executeGitCommand(statsCommand); const commitStats = new Map(); if (!output) { return commitStats; } const lines = output.split("\n"); let currentCommit = ""; for (const line of lines) { if (line.match(/^[a-f0-9]{40}$/)) { // This is a commit hash currentCommit = line; commitStats.set(currentCommit, { added: 0, deleted: 0 }); } else if (currentCommit && line.includes("\t")) { // This is a numstat line const parts = line.split("\t"); if (parts.length >= 2) { const added = parts[0] === "-" ? 0 : parseInt(parts[0]) || 0; const deleted = parts[1] === "-" ? 0 : parseInt(parts[1]) || 0; const stats = commitStats.get(currentCommit); stats.added += added; stats.deleted += deleted; } } } return commitStats; } catch (error) { console.warn(`Warning: Could not get optimized stats, falling back to individual commits`); return new Map(); } } getCommitStats(commitHash) { try { const statsCommand = `git show --numstat --format="" ${commitHash}`; const output = this.executeGitCommand(statsCommand); let totalAdded = 0; let totalDeleted = 0; if (output) { const lines = output.split("\n").filter((line) => line.trim()); for (const line of lines) { const parts = line.split("\t"); if (parts.length >= 2) { const added = parts[0] === "-" ? 0 : parseInt(parts[0]) || 0; const deleted = parts[1] === "-" ? 0 : parseInt(parts[1]) || 0; totalAdded += added; totalDeleted += deleted; } } } return { added: totalAdded, deleted: totalDeleted }; } catch (error) { console.warn(`Warning: Could not get stats for commit ${commitHash}`); return { added: 0, deleted: 0 }; } } aggregateStatsByAuthor(commits) { const authorStats = new Map(); console.log(`Processing ${commits.length} commits...`); // Try optimized approach first const allCommitStats = this.getAllCommitStatsOptimized(); const useOptimized = allCommitStats.size > 0; if (useOptimized) { console.log("Using optimized stats collection..."); } commits.forEach((commit, index) => { if (index % Math.max(1, Math.floor(commits.length / 10)) === 0) { const percentage = Math.round((index / commits.length) * 100); console.log(`Progress: ${percentage}% (${index + 1}/${commits.length} commits)`); } const authorKey = commit.email; if (!authorStats.has(authorKey)) { authorStats.set(authorKey, { author: commit.author, email: commit.email, linesAdded: 0, linesDeleted: 0, netLines: 0, commits: 0, kloc: 0, }); } const stats = authorStats.get(authorKey); // Use optimized stats if available, otherwise fall back to individual commit stats const commitStats = useOptimized ? allCommitStats.get(commit.hash) || { added: 0, deleted: 0 } : this.getCommitStats(commit.hash); stats.linesAdded += commitStats.added; stats.linesDeleted += commitStats.deleted; stats.commits += 1; }); // Calculate net lines and KLOC authorStats.forEach((stats) => { stats.netLines = stats.linesAdded - stats.linesDeleted; stats.kloc = Math.round((stats.linesAdded / 1000) * 100) / 100; // Round to 2 decimal places }); return authorStats; } async analyze() { console.log(`Analyzing repository: ${this.repoPath}`); console.log(`Date range: ${this.fromDate} to ${this.toDate}`); this.validateRepository(); this.validateDateRange(); const commits = this.getCommitsInRange(); console.log(`Found ${commits.length} commits in the specified date range`); if (commits.length === 0) { console.log("No commits found in the specified date range"); return []; } const authorStats = this.aggregateStatsByAuthor(commits); // Convert to array and sort by KLOC descending const results = Array.from(authorStats.values()).sort((a, b) => b.kloc - a.kloc); console.log("\nAnalysis completed successfully!"); return results; } } exports.GitKlocAnalyzer = GitKlocAnalyzer; /** * Formats a number with thousand separators */ function formatNumber(num) { return num.toLocaleString(); } /** * Displays the analysis results in a formatted table */ function displayResults(stats) { if (stats.length === 0) { console.log("No contributors found in the specified date range."); return; } // Calculate dynamic column widths based on content // Email width needs to account for rank prefix (#01, #02, etc.) + space + email const maxEmailLength = Math.max(...stats.map(s => s.email.length)); const rankWidth = stats.length.toString().length + 1; // +1 for the # symbol const emailWidth = Math.max(30, maxEmailLength + rankWidth + 3); // +3 for space and padding const addedWidth = Math.max(12, Math.max(...stats.map(s => formatNumber(s.linesAdded).length)) + 2); const deletedWidth = Math.max(12, Math.max(...stats.map(s => formatNumber(s.linesDeleted).length)) + 2); const netWidth = Math.max(12, Math.max(...stats.map(s => formatNumber(s.netLines).length)) + 2); const commitsWidth = Math.max(10, Math.max(...stats.map(s => formatNumber(s.commits).length)) + 2); const klocWidth = 10; // Create the table border const borderLine = "+" + "-".repeat(emailWidth) + "+" + "-".repeat(klocWidth) + "+" + "-".repeat(addedWidth) + "+" + "-".repeat(deletedWidth) + "+" + "-".repeat(netWidth) + "+" + "-".repeat(commitsWidth) + "+"; console.log("\n" + "=".repeat(borderLine.length)); console.log("GIT KLOC STATISTICS REPORT".padStart((borderLine.length + 27) / 2)); console.log("=".repeat(borderLine.length)); console.log(); console.log(borderLine); console.log(`| ${"Email".padEnd(emailWidth - 2)} | ${"KLOC".padStart(klocWidth - 2)} | ${"Added".padStart(addedWidth - 2)} | ${"Deleted".padStart(deletedWidth - 2)} | ${"Net".padStart(netWidth - 2)} | ${"Commits".padStart(commitsWidth - 2)} |`); console.log(borderLine); let totalAdded = 0; let totalDeleted = 0; let totalCommits = 0; let totalKloc = 0; stats.forEach((stat, index) => { const rank = `#${(index + 1).toString().padStart(stats.length.toString().length, '0')}`; const emailDisplay = `${rank} ${stat.email}`; console.log(`| ${emailDisplay.padEnd(emailWidth - 2)} | ${stat.kloc.toFixed(2).padStart(klocWidth - 2)} | ${formatNumber(stat.linesAdded).padStart(addedWidth - 2)} | ${formatNumber(stat.linesDeleted).padStart(deletedWidth - 2)} | ${formatNumber(stat.netLines).padStart(netWidth - 2)} | ${formatNumber(stat.commits).padStart(commitsWidth - 2)} |`); totalAdded += stat.linesAdded; totalDeleted += stat.linesDeleted; totalCommits += stat.commits; totalKloc += stat.kloc; }); console.log(borderLine); console.log(`| ${"TOTAL".padEnd(emailWidth - 2)} | ${totalKloc.toFixed(2).padStart(klocWidth - 2)} | ${formatNumber(totalAdded).padStart(addedWidth - 2)} | ${formatNumber(totalDeleted).padStart(deletedWidth - 2)} | ${formatNumber(totalAdded - totalDeleted).padStart(netWidth - 2)} | ${formatNumber(totalCommits).padStart(commitsWidth - 2)} |`); console.log(borderLine); // Summary statistics console.log(); console.log("Summary:"); console.log(`- Total contributors: ${stats.length}`); console.log(`- Average KLOC per contributor: ${(totalKloc / stats.length).toFixed(2)}`); console.log(`- Average commits per contributor: ${(totalCommits / stats.length).toFixed(1)}`); if (stats.length > 0) { console.log(`- Top contributor: ${stats[0].email} (${stats[0].kloc.toFixed(2)} KLOC)`); } } /** * Escapes CSV field content to handle quotes and commas properly */ function escapeCsvField(field) { if (field.includes('"') || field.includes(",") || field.includes("\n")) { return `"${field.replace(/"/g, '""')}"`; } return field; } /** * Saves contributor statistics to a CSV file */ function saveToFile(stats) { try { // Generate filename with current date const now = new Date(); const dateStr = now.getFullYear().toString() + (now.getMonth() + 1).toString().padStart(2, "0") + now.getDate().toString().padStart(2, "0") + now.getHours().toString().padStart(2, "0") + now.getMinutes().toString().padStart(2, "0") + now.getSeconds().toString().padStart(2, "0"); const filename = `kloc_${dateStr}.csv`; const csvContent = [ "Author,Email,KLOC,Lines Added,Lines Deleted,Net Lines,Commits", ...stats.map((stat) => `${escapeCsvField(stat.author)},${escapeCsvField(stat.email)},${stat.kloc},${stat.linesAdded},${stat.linesDeleted},${stat.netLines},${stat.commits}`), ].join("\n"); fs.writeFileSync(filename, csvContent, "utf-8"); return filename; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`Error saving file: ${errorMessage}`); throw error; } } /** * Main function to run the CLI application */ async function main() { const program = new commander_1.Command(); program .name("git-kloc-analyzer") .description("Analyze KLOC (Kilo Lines of Code) statistics for Git contributors") .version("1.1.0") .requiredOption("-r, --repo <path>", "Path to the Git repository") .option("-f, --from <date>", "Start date (YYYY-MM-DD), defaults to repository start") .option("-t, --to <date>", "End date (YYYY-MM-DD), defaults to current date") .option("-o, --output <file>", "Save results to CSV file") .option("--no-progress", "Disable progress output") .parse(); const options = program.opts(); try { const startTime = Date.now(); // Set default dates if not provided const fromDate = options.from || "1970-01-01"; // Unix epoch start const toDate = options.to || new Date().toISOString().split("T")[0]; // Today's date const analyzer = new GitKlocAnalyzer(options.repo, fromDate, toDate); const stats = await analyzer.analyze(); displayResults(stats); // Always save to CSV file const csvFilename = saveToFile(stats); console.log(`\nTotal execution time: ${((Date.now() - startTime) / 1000).toFixed(2)} seconds`); console.log(`CSV file saved: ${csvFilename}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error("Error:", errorMessage); process.exit(1); } } if (require.main === module) { main(); } //# sourceMappingURL=kloc.js.map