
先说一件很有意思的事。很多年前的一场颁奖礼上Beyond 输给了草蜢。多年以后“草蜢”这个名字依然有很高的知名度可你随便拉住一个同事问草蜢的三位成员分别叫什么大概率答不上来。反过来Beyond 的成员黄家驹、黄贯中、黄家强、叶世荣反而被不少人记住了。为什么会这样因为大众记住的往往是一个“组合名”而不是组合背后具体的人。代码世界每天都在发生同样的事情。很多开发者用着某个开源框架、某个中间件却完全不知道它的核心维护者是谁很多项目在团队里跑了好几年工具链、文档、评审记录都在但如果你问负责人这个项目真正由哪几个人写出来的他可能也说不清楚。这篇文章不打算聊音乐而是借这个引子聊一个非常实际的问题如何用 Git 把项目背后的“乐队成员”找出来。读完你会掌握git shortlog、git log --format、git blame、.mailmap这些命令的用法并且拿得到一个完整可运行的 Python 脚本用来生成一份项目贡献度报表。无论是给团队做复盘、写晋升材料还是维护开源项目时想了解外部贡献者分布这套东西都直接用得上。1. 名字好记不等于贡献可查先解释一下我为什么想写这个话题。企业在做项目复盘时经常需要回答一个问题这个系统是谁写的大多数人的第一反应是看代码提交记录。可真正打开 Git 日志以后你会发现统计出来的结果往往和“印象中的贡献者”对不上。有人提交次数很多但全是格式调整有人一个上万行的核心模块只有三次提交有人改了别人写的代码git blame一路追下去却指向了原作者的 commit同一个人在仓库里可能因为换了公司邮箱被统计成三个不同的人。所以“知道项目”并不等于“知道贡献者”。Git 作为版本管理工具天然记录着每个文件、每一行代码、每一次提交的来源信息但这些信息需要正确的命令和方法才能被提取出来。我们的目标就是把这些信息变成一份清晰、客观、可导出的贡献度数据。这里也要先提前说明Git 统计出来的“贡献”不等于“价值”。一个只能写三行核心代码的人可能比整天提交文档的人更关键一个代码量不大但负责架构设计、代码评审的人在 Git 数据里可能完全不显眼。所以本文讲的是一个量化维度它不能替代管理判断但可以作为重要的参考依据。2. 环境准备与版本说明在开始动手之前我们先确认环境。下面这些工具和版本要求并不复杂都是日常开发标配。Git建议使用 2.x 及以上版本。本文涉及的命令在 2.17 环境中测试过低版本可能缺少部分参数但核心命令都可用。Python建议使用 3.8 及以上版本用于运行最后的贡献度报表脚本。操作系统Windows、macOS、Linux 都可以。Windows 下建议使用 Git Bash 或系统自带的终端Python 脚本在 CMD 中也能运行。IDE不是必需命令行加一个编辑器就够。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。整个操作流程不依赖任何特殊权限也不需要联网。你可以对本地任意一个 Git 仓库执行命令演示脚本我会准备一个模拟仓库方便你完整体验从创建仓库到生成报表的过程。3. 核心概念Git 是怎样记录贡献者的要准确统计贡献者首先得理解 Git 里的几个基本概念。很多人用过 Git 几年却从没有认真看过提交对象里到底存了哪些字段。3.1 author 与 committer 的区别每次执行git commit时Git 会往提交对象里写入两个身份author代码的编写者。committer真正执行提交操作、把这次提交写入仓库的人。绝大多数时候这两个身份是同一个。但在开源协作场景里它们经常出现差异。例如某个开发者通过邮件列表发来一个补丁维护者审核通过后用git am或git cherry-pick把它合入仓库那么 author 是补丁原作者committer 是维护者。如果你发现一个仓库里的提交记录存在身份不一致不要觉得奇怪这是正常的协作痕迹。查看方式git log --format%h | %an | %cn | %s其中%an是 author 名字。%cn是 committer 名字。%h是短提交哈希。%s是提交说明。3.2 为什么要同时看多个统计维度只看提交次数是最常见的误区。网上很多仓库的贡献者排行榜用的是提交次数但这个指标容易被刷一次顺手改一个空格也能算一次提交。相比之下下面几个维度更值得关注维度说明提交次数反映参与频率适合初步排名。新增/删除行数反映代码改动的体量。涉及文件数反映改动范围。单次提交平均改动反映提交拆分是否合理。首次/最近提交时间反映参与周期和活跃度。真实项目中这几个维度需要综合看。一个人提交 50 次但只改了 100 行另一个人提交 5 次但改了一个 2000 行的核心模块价值显然不能只看次数。3.3 .mailmap 是什么Git 默认用邮箱来区分作者同一个人的不同邮箱会被当成不同的人。.mailmap文件的作用就是把“同一个人的多个身份”映射成一个规范身份。它一般放在仓库根目录并提交到版本控制里这样全团队共享同一份身份映射。例如真实姓名 realexample.com 曾用名 oldexample.com 真实姓名 realexample.com 曾用邮箱 anotherexample.com配置完成后使用--use-mailmap参数的 Git 命令就会按映射后的身份来统计。4. 基础命令从 Git 里找出“乐队成员”下面逐一介绍高频命令。可以先在一个真实的项目仓库里试运行再结合自己的项目理解输出格式。4.1 git shortlog -sn提交次数排行这是最直接、最常用的贡献者排名命令。git shortlog -sn参数含义-s表示只显示提交次数和作者名不列出具体提交。-n表示按次数降序排序。如果想合并同一个人的邮箱可以加--use-mailmapgit shortlog -sn --use-mailmap输出示例12 张三 8 李四 5 王五要注意的是git shortlog默认按作者名分组如果同一个人在不同时期使用了不同用户名或大小写也会被分成多组。4.2 git log --format精确提取提交字段git shortlog适合快速看排行但要拿到更多信息需要使用git log配合--format自定义输出。git log --format%h | %an | %ae | %ad | %s --dateshort这段输出里每一行包含字段含义%h短提交哈希%an作者名%ae作者邮箱%ad作者日期%s提交说明还可以把输出写入文件方便后续脚本处理git log --format%h|%an|%ae|%ad|%s --dateshort commits.txt这个格式非常适合写脚本解析后面的实战部分会用到类似思路。4.3 git blame定位每一行代码的作者git blame是追查“某行代码谁来背锅”的神器。它会逐行显示文件的每一行是在哪个提交、由谁、在什么时候加入或修改的。git blame src/main.py输出示例a1b2c3d4 (张三 2025-01-10 10:20:30 1) import os e5f6a7b8 (李四 2025-01-12 15:30:22 2) from flask import Flask如果仓库配置了.mailmap可以加参数启用git blame --use-mailmap src/main.py使用git blame时有一个常见误区它不是统计“谁写了多少行代码”而是定位每一行的最后修改者。某一行代码虽然最早是张三写的但后来李四重构时改过这一行blame就会显示李四。所以它更适合定位具体问题不适合直接做贡献度统计。4.4 .mailmap合并身份当同一个开发者的提交出现多个邮箱时可以用.mailmap合并。文件位置仓库根目录.mailmap内容格式规范名字 规范邮箱 曾用名字 曾用邮箱 规范名字 规范邮箱 曾用邮箱 任意旧邮箱例如张三曾经用过zhangsancorp.com和zhangsanqq.com统一写法如下张三 zhangsancorp.com 张三 zhangsanqq.com配置完成后执行git shortlog -sn --use-mailmap git log --use-mailmap --format%h | %an | %ae | %s你会看到两个邮箱的提交被合并到张三 zhangsancorp.com底下。4.5 git log --numstat统计增删行数如果想看每个提交具体改了多少行可以使用--numstatgit log --numstat --format%H %an %ae--numstat的输出每行由“新增行数、删除行数、文件名”组成。二进制文件不统计行数用-表示。组合起来看这种输出能同时拿到提交元信息和变更量是后面脚本的核心数据来源。5. 完整实战写一个 Git 贡献度报表脚本现在我们把上面的命令组合成一个完整可运行的工具。目标是输入一个仓库路径输出一份作者贡献度报表包含提交次数、新增行数、删除行数、涉及文件数、首次提交时间和最近提交时间。5.1 创建演示仓库先创建一个模拟仓库用来验证脚本效果。这里模拟了三个开发者、不同的邮箱、多个提交的简单场景。mkdir demo-repo cd demo-repo git init # 配置第一段身份 git config user.name 张三 git config user.email zhangsanexample.com echo print(hello) app.py git add app.py git commit -m initial commit # 切换到李四身份 git config user.name 李四 git config user.email lisiexample.com echo import sys app.py echo print(sys.argv) app.py git add app.py git commit -m add argv support # 切回张三但使用另一个邮箱用来模拟身份分裂 git config user.name zhangsan git config user.email zscorp.com echo # demo README.md git add README.md git commit -m add readme # 王五新增一个工具模块 git config user.name 王五 git config user.email wangwuexample.com mkdir utils echo def add(a, b): return a b utils/math.py git add . git commit -m add math utils以上命令创建了一个有 4 次提交的仓库并且张三使用了两个不同的邮箱。接下来运行脚本时我们会看到张三被拆成两条记录这就是身份分裂现象的直观体现。5.2 脚本设计思路脚本采用subprocess调用git log一次性拿到--numstat格式的原始数据再按如下逻辑处理用git log --numstat --prettyformat:...获取提交元信息和变更量。逐行解析遇到___COMMIT___前缀表示一条新提交开始。其余行按新增行数 删除行数 文件名解析变更量。按作者邮箱聚合生成报表。支持 CSV 和 JSON 两种输出格式。这样做的好处是不依赖第三方 Git 库只要机器上装了 Git 就能运行。5.3 完整脚本代码文件路径git_contrib_report.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- git_contrib_report.py 从 Git 仓库中提取提交记录按作者统计 - 提交次数 - 新增行数 - 删除行数 - 涉及文件数 - 首次提交 / 最近提交时间 用法示例 python3 git_contrib_report.py /path/to/repo --since2025-01-01 --until2025-12-31 --formatcsv -o report.csv import argparse import csv import json import re import subprocess import sys from collections import defaultdict class Commit: def __init__(self, commit_id, author, email, date, subject): self.commit_id commit_id self.author author self.email email self.date date self.subject subject self.added 0 self.deleted 0 self.files [] def run_git(repo_path, args): cmd [git, -C, repo_path] args proc subprocess.run( cmd, capture_outputTrue, textTrue, encodingutf-8, errorsreplace ) if proc.returncode ! 0: print(git 命令执行失败:, .join(cmd), filesys.stderr) print(proc.stderr, filesys.stderr) sys.exit(1) return proc.stdout def parse_git_log(repo_path, since, until): cmd [ log, --numstat, --prettyformat:___COMMIT___%H|%an|%ae|%ad|%s, --dateshort, ] if since: cmd.append(--since%s % since) if until: cmd.append(--until%s % until) output run_git(repo_path, cmd) commits [] current None numstat_pattern re.compile(r^(\d|-)\t(\d|-)\t(.*)$) for line in output.splitlines(): if line.startswith(___COMMIT___): if current: commits.append(current) _, commit_id, author, email, date, subject line.split(|, 5) current Commit(commit_id, author, email, date, subject) else: match numstat_pattern.match(line) if match: added match.group(1) deleted match.group(2) filename match.group(3) if current is None: continue current.added 0 if added - else int(added) current.deleted 0 if deleted - else int(deleted) current.files.append(filename) if current: commits.append(current) return commits def build_report(commits): report defaultdict(lambda: { author: , email: , commit_count: 0, added: 0, deleted: 0, files: set(), first_commit: None, last_commit: None, }) for c in commits: item report[c.email] item[author] c.author item[email] c.email item[commit_count] 1 item[added] c.added item[deleted] c.deleted item[files].update(c.files) if item[first_commit] is None or c.date item[first_commit]: item[first_commit] c.date if item[last_commit] is None or c.date item[last_commit]: item[last_commit] c.date return report def output_csv(report, out_file): writer csv.writer(out_file) writer.writerow([ 作者, 邮箱, 提交次数, 新增行数, 删除行数, 涉及文件数, 首次提交, 最近提交 ]) for email in sorted(report.keys()): item report[email] writer.writerow([ item[author], item[email], item[commit_count], item[added], item[deleted], len(item[files]), item[first_commit], item[last_commit], ]) def output_json(report, out_file): data [] for email in sorted(report.keys()): item report[email] data.append({ author: item[author], email: item[email], commit_count: item[commit_count], added_lines: item[added], deleted_lines: item[deleted], file_count: len(item[files]), first_commit: item[first_commit], last_commit: item[last_commit], }) json.dump(data, out_file, ensure_asciiFalse, indent2) def main(): parser argparse.ArgumentParser(description生成 Git 仓库贡献度报表) parser.add_argument(repo, help仓库路径例如 . 表示当前目录) parser.add_argument(--since, help起始日期例如 2025-01-01) parser.add_argument(--until, help截止日期例如 2025-12-31) parser.add_argument(--format, choices[csv, json], defaultcsv, help输出格式) parser.add_argument(--output, -o, help输出文件路径默认输出到标准输出) args parser.parse_args() commits parse_git_log(args.repo, args.since, args.until) if not commits: print(没有找到提交记录请检查仓库路径、日期范围或 Git 版本。) sys.exit(1) report build_report(commits) if args.output: with open(args.output, w, encodingutf-8-sig, newline) as f: if args.format csv: output_csv(report, f) else: output_json(report, f) print(报表已生成:, args.output) else: if args.format csv: output_csv(report, sys.stdout) else: output_json(report, sys.stdout) if __name__ __main__: main()脚本里有两个值得注意的细节。第一encodingutf-8-sig会在 CSV 文件开头写入 BOM这样用 Windows 的 Excel 打开中文不会乱码。第二--numstat对二进制文件不统计行数解析时用0 if added -做了兜底处理。5.4 运行与验证在仓库目录外执行脚本指定仓库路径为demo-repopython3 git_contrib_report.py demo-repo --formatcsv -o report.csv如果是在仓库内部运行仓库路径可以直接写.python3 git_contrib_report.py . --formatcsv也可以只统计某一段时间的贡献比如只看 2025 年的提交python3 git_contrib_report.py . --since2025-01-01 --until2025-12-31 --formatjson5.5 结果说明在演示仓库上运行后CSV 内容会类似作者邮箱提交次数新增行数删除行数涉及文件数首次提交最近提交zhangsanzscorp.com13012025-...2025-...张三zhangsanexample.com11012025-...2025-...李四lisiexample.com12012025-...2025-...王五wangwuexample.com13022025-...2025-...观察输出可以看到同一个真实开发者“张三”因为使用了两个不同的邮箱被统计成了两行。这就是前面说的身份分裂问题。要合并这两行可以在仓库根目录添加.mailmap内容张三 zhangsanexample.com zhangsan zscorp.com然后给 Git 命令加上--use-mailmap参数。如果要让脚本也支持这个能力可以在脚本的parse_git_log命令中加入--use-mailmap这样git log输出时就已经完成了身份合并。6. 常见问题与排查思路在真实项目中使用这套统计方法时下面几个问题出现频率最高。问题现象常见原因解决思路同一个开发者被统计成多行使用了不同邮箱或用户名使用.mailmap统一身份配合--use-mailmapgit blame显示的不是我预期的人该行后来被其他人修改过blame只显示每行最后修改者不代表最初作者CSV 用 Excel 打开中文乱码文件使用了 UTF-8 无 BOM 编码脚本输出utf-8-sig或手动用编辑器转码统计结果和 GitHub 贡献图不一致GitHub 的统计规则和本地 Git 逻辑不同以本地 Git 数据为准结合 GitHub API 做参照二进制文件没有增删行数--numstat无法统计二进制变更量不适用于行数统计改用文件名变更次数作为参考git shortlog输出顺序不稳定不同版本排序规则有差异加-n参数并按数字排序6.1 为什么统计结果里同一个人有多行这是最常见的困惑。Git 识别身份的关键是邮箱不是用户名。只要一个人换过邮箱旧记录就会被当成另一个人处理。最彻底的解决方法是规范提交时的user.email配置同时在项目根目录维护.mailmap。6.2 git blame 的作者名不对如果文件曾经被格式化工具批量处理或者某一行被重构过blame显示的就是最后一次改动的人。排查时不要只看作者名还要看 commit hash 和提交时间结合git log查看这次修改的上下文。git log -p --follow -- src/main.py6.3 统计结果与 GitHub 不一致GitHub 的贡献图有自己的统计算法比如合并提交的作者、空提交、以及被 rebase 的提交都可能影响结果。本地统计和 GitHub 页面不一致是正常现象。需要对外呈现数据时建议只采用一种口径并在文档中说明统计方法。6.4 忘记配置 user.name 和 user.email如果没有在全局或仓库级别配置提交者信息Git 会尝试从系统用户名猜测。这会导致贡献者身份混乱。检查方式git config --list如果发现身份信息不对及时修正git config user.name 规范姓名 git config user.email 规范邮箱需要说明的是修改配置只会影响之后的提交历史提交需要用git filter-branch或git filter-repo这样的工具才能改写不建议在非必要情况下回写历史。6.5 仓库太大脚本执行慢如果仓库历史非常长一次性git log --numstat可能会较慢。可以先用--since限定时间范围或者只统计某个分支。对于超大仓库建议按月份分批统计避免占用过多内存。7. 工程实践让团队贡献“被看见”Beyond 输给草蜢这件事从技术视角看是一个“可见度”问题。草蜢作为组合名被记住了成员个人反而没有。代码世界里也差不多一个系统跑得好好的却没人知道核心开发者是谁等到某个人离职大家才发现系统只有他一个人懂。下面是几条能让团队贡献被准确记录、被客观看见的工程建议。7.1 规范提交者信息从仓库创建第一天就要求提交者使用统一身份。公司内部可以硬性规定git config user.name 张三 git config user.email zhangsancorp.com如果涉及多个项目统一使用全局配置git config --global user.name 张三 git config --global user.email zhangsancorp.com提交信息规范后后续所有统计才可能准确。否则身份分裂问题会持续污染历史记录。7.2 把 .mailmap 纳入版本库.mailmap应该像.gitignore一样提交到仓库根目录这样任何人在任何机器上跑统计命令得到的结果都是一致的。文档里同时写清楚使用方式git shortlog -sn --use-mailmap7.3 不要只看 Git 数据既然 Beyond 和草蜢的故事里真正让人惋惜的是“评委只看了名次”我们更应该提醒自己不要只看单一排名。Git 贡献度报表只是量化参考还需要结合代码评审记录、MR/PR 讨论、架构文档来综合评估一个人的贡献。如果使用 GitLab 或 GitHub可以通过 API 拉取 MR 数量、评论数、评审次数等数据和本地 Git 统计互为补充。7.4 识别单点风险用本文脚本跑完报表后如果发现某个核心模块比如支付系统、订单核心逻辑长时间只有一个人修改这就是典型的单点风险。此时要做的是代码评审、知识分享和文档沉淀而不是马上让其他人接手乱改。至少要让团队知道这个“主唱”很重要但其他人也要逐渐进来至少能看懂、能评审。7.5 把贡献度数据用于团队建设每个月跑一次贡献度报表在周会或技术分享上展示可以帮助新人理解项目的演进脉络。新人可以通过git blame找到关键模块的代码作者直接找对人提问比在群里大范围求助高效得多。8. 写在最后从 Beyond 和草蜢的往事说起最后落回了一个很具体的工作技能用 Git 统计项目贡献者。本质上是想说明一件事——名字是否被记住很多时候取决于曝光度但在代码世界里Git 会诚实地记住每一行代码来自谁。本文核心内容可以总结为几点理解 Git 中 author 与 committer 的区别是正确解读提交记录的前提。git shortlog -sn适合快速看提交次数排行。git blame适合定位每一行代码的修改者但不等于贡献度统计。.mailmap可以把同一个人的多个身份合并起来。一个完整的 Python 脚本可以直接生成 CSV 或 JSON 格式的贡献度报表。如果你还没在自己的项目上试过建议马上找一个仓库跑一下这几个命令git shortlog -sn git log --format%h | %an | %ae | %ad | %s --dateshort git blame --use-mailmap src/main.py然后跑一遍上面的 Python 脚本看看项目里真正的核心贡献者是谁。也许你会发现平时最活跃的人不是产出最高的人平时话最少的那个人反而默默撑起了系统几个最关键的模块。