← 中文资源

简体中文版本

构建 Git 提交统计命令行工具

解析 git log,按作者、日期与文件统计提交与增删行,突出改动最频繁的文件。

阅读英文原文

项目目标

本项目用一个自定义分隔格式运行 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);
}