项目目标
本项目用一个自定义分隔格式运行 git log 并解析每条提交,把作者、日期与文件信息汇总成多张报表,全程不引入重型依赖。提交一旦成为带 files 数组的记录,任何报表都只是同一数据的不同分组,而无需再次调用 git。
核心特性
- 以可解析格式读取 git log 并逐条解析
- 按作者统计提交数并计入增删行
- 按日与星期聚合提交以显示节奏
- 列出改动最频繁的文件
架构与分层
git 运行器通过子进程执行 git log;解析器把文本转为提交记录;聚合器从同一记录集计算多个视图;格式化器把每个视图打印成表格。解析与聚合都是纯函数,可在无仓库时测试。
实施阶段
六个阶段为:git 调用、日志解析、聚合、日期聚合、表格格式化、CLI 与测试。numstat 中的二进制行以横线表示,必须按零处理。
测试与验收
用 vitest 运行。解析测试喂入样例日志断言字段、numstat 与二进制文件;聚合测试断言作者总计、按日计数与文件改动数,含被两次提交触及的文件。
延伸方向
日期范围是聚合前的过滤,路径过滤是一个谓词,导出 CSV 或 JSON 是新格式化器。这些都不触及 git 运行器与解析器。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export const LOG_FORMAT = [
'--pretty=format:__COMMIT__%H%x09%an%x09%aI%x09%s',
'--numstat',
].join(' ');
export function parseLog(text) {
return text
.split('__COMMIT__')
.filter((chunk) => chunk.trim())
.map((chunk) => {
const [header, ...fileLines] = chunk.split('\n');
const [hash, author, date, subject] = header.split('\t');
const files = fileLines
.map((l) => l.split('\t'))
.filter((parts) => parts.length === 3 && parts[0] !== '-')
.map(([insertions, deletions, path]) => ({
path,
insertions: Number(insertions),
deletions: Number(deletions),
}));
return { hash, author, date, subject, files };
});
}
mkdir git-stats && cd git-stats
npm init -y
npm install -D vitest@^2.1.0
{
"name": "git-stats",
"type": "module",
"scripts": {
"stats": "node src/cli.js",
"test": "vitest run"
}
}
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export const LOG_ARGS = [
'log',
'--pretty=format:__COMMIT__%H%x09%an%x09%aI%x09%s',
'--numstat',
'--no-merges',
];
export async function gitLog(cwd = process.cwd(), extra = []) {
try {
const { stdout } = await run('git', [...LOG_ARGS, ...extra], { cwd, maxBuffer: 64 * 1024 * 1024 });
return stdout;
} catch (err) {
throw new Error('git log failed: ' + err.message);
}
}
export function parseLog(text) {
return text
.split('__COMMIT__')
.filter((chunk) => chunk.trim())
.map((chunk) => {
const [header, ...fileLines] = chunk.split('\n');
const [hash, author, date, subject] = header.split('\t');
const files = fileLines
.map((l) => l.split('\t'))
.filter((parts) => parts.length === 3)
.map(([insertions, deletions, path]) => ({
path,
insertions: insertions === '-' ? 0 : Number(insertions),
deletions: deletions === '-' ? 0 : Number(deletions),
binary: insertions === '-',
}));
return { hash, author, date, subject, files };
});
}
export function byAuthor(commits) {
const map = new Map();
for (const c of commits) {
const entry = map.get(c.author) ?? { author: c.author, commits: 0, insertions: 0, deletions: 0 };
entry.commits++;
for (const f of c.files) { entry.insertions += f.insertions; entry.deletions += f.deletions; }
map.set(c.author, entry);
}
return [...map.values()].sort((a, b) => b.commits - a.commits || a.author.localeCompare(b.author));
}
export function byFile(commits) {
const map = new Map();
for (const c of commits) for (const f of c.files) map.set(f.path, (map.get(f.path) ?? 0) + 1);
return [...map.entries()].map(([path, changes]) => ({ path, changes }))
.sort((a, b) => b.changes - a.changes || a.path.localeCompare(b.path));
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function byDay(commits) {
const map = new Map();
for (const c of commits) {
const day = c.date.slice(0, 10);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()].map(([day, commits]) => ({ day, commits })).sort((a, b) => a.day.localeCompare(b.day));
}
export function byWeekday(commits) {
const counts = Object.fromEntries(WEEKDAYS.map((d) => [d, 0]));
for (const c of commits) counts[WEEKDAYS[new Date(c.date).getUTCDay()]]++;
return counts;
}
export function table(rows, columns) {
const widths = columns.map((col) =>
Math.max(col.label.length, ...rows.map((r) => String(r[col.key]).length)));
const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ');
const lines = rows.map((r) => columns.map((col, i) => String(r[col.key]).padEnd(widths[i])).join(' '));
return [header, '-'.repeat(header.length), ...lines].join('\n');
}
import { gitLog } from './run.js';
import { parseLog } from './parse.js';
import { byAuthor, byFile } from './aggregate.js';
import { table } from './format.js';
const [view = 'authors', ...extra] = process.argv.slice(2);
const commits = parseLog(await gitLog(process.cwd(), extra));
if (view === 'authors') {
console.log(table(byAuthor(commits), [
{ key: 'author', label: 'Author' }, { key: 'commits', label: 'Commits' },
{ key: 'insertions', label: '+' }, { key: 'deletions', label: '-' },
]));
} else if (view === 'files') {
console.log(table(byFile(commits), [{ key: 'path', label: 'File' }, { key: 'changes', label: 'Changes' }]));
} else {
console.error('usage: git-stats [authors|files|days] [git args]');
process.exit(1);
}