cua:基于Git工作区的代码变更分析与提交前检查工具

📅 发布时间:2026/10/10 10:13:52
cua:基于Git工作区的代码变更分析与提交前检查工具
这项目最初真不在计划里。某个周二的下午我盯着终端里几百行git diff发愣意识到自己几乎每天都要做同一件没什么技术含量的事人肉总结变更。三个仓库来回切前后端代码混在一起提交信息全靠现场编周报更是灾难。于是断断续续写了一个小工具名字就叫cua全称可以硬凑成 Code Update Analyzer——但说实话名字是先定了三个顺手好敲的字母再回头编的全称。cua 做的事情很简单站在一个 Git 仓库的工作区里自动抓取所有未提交的改动按模块整理成人类能看懂的变更摘要同时跑一组预置规则检查调试残留、冲突标记、超大文件这类提交前最好处理掉的问题。它适合两类人一类是常在多仓库之间切换、每次提交前都要费劲回忆改了什么的人另一类是希望在做 Code Review 之前先用机器把明显问题过滤一遍的团队。这个项目最让我满意的地方不是代码量而是那个把日常烦琐动作固化成规则的思路。这篇文章就把整个设计过程、核心实现和踩过的坑按我真实操作的顺序写出来。1. cua 的起点被 git diff 逼疯的日常1.1 项目最初的三个名字动手之前我其实列过十几个工具名。第一个想法是叫gd寓意 Git Diff 的缩写但在终端里输入gd太容易误触而且跟很多命令冲突。第二个名字很长叫what-changed功能倒是直白可每次敲完都觉得自己在打一个句子效率太低。最后定成 cua纯粹是因为键盘上手感好三个字母左手换右手敲完刚好回车没有任何停顿感。后来为了凑全称我翻了半天字典定了 Code Update Analyzer但日常使用里从来没人会念全称大家就是 敲一下 cua。项目的定位也在这个过程中清晰起来它不是一个文件监听工具也不是某种自动提交流水线而是一个基于 Git 工作区的变更分析器输出结论不替你动手。1.2 功能边界cua 只管看不管改早期我差点把工具做成什么都管分析 CompletableFuture 用法、检查依赖版本、自动格式化代码、甚至试图给提交信息打分。做到一半发现方向错了——这类分析器最大的敌人是功能膨胀。最终把边界收敛成四条抓取工作区相对 HEAD 的变更事实新增、删除、修改、重命名。把变更按顶层目录/模块聚合生成可读摘要。在变更文件范围内跑规则检查返回发现问题/建议处理级别的结果。输出两种格式终端看的人读版、给脚本消费的 JSON 版。明确不管的事有不改代码、不自动提交、不做全仓库历史分析。为什么这么克制因为工具的可替代性极高今天我用 cua明天不用了也完全不影响开发流程。一旦它开始自动改文件就变成一个高风险系统用户信任成本立刻起飞。保持只读和只建议是让团队愿意长期使用的基础。2. 为什么做成命令行工具而不是编辑器插件2.1 五种运行形态的对比动手前我认真评估过几种形态VSCode 扩展、命令行工具、Git Hook、CI 流水线以及一个带 GUI 的独立程序。五者各有各的道理但适用场景完全不同。形态上手成本跨平台适用场景主要问题编辑器插件低依赖编辑器日常交互、可视化看 diff不能脱离 IDE难自动化命令行工具中好多仓库、脚本、SSH 环境需要终端使用习惯Git Hook中好提交前强制检查规则误报会阻塞提交CI 流水线高好代码入库后的统一检查反馈链路太长GUI 程序低一般展示型场景开发维护成本最高我最后选的是命令行工具为主、Git Hook 为可选配件。理由很直白命令行工具能跑在任何操作系统上能接pre-commit按需执行也能被 Jenkins 之类的 CI 系统直接调用输出的 JSON 天然可被其他程序消费。编辑器插件看着方便但产品绑死了编辑器生态对我的伪需求来说性价比太低。2.2 语言与依赖Python 加底层 git 绑定语言选型上我承认自己有私心Python 写这类文本分析规则判定最顺手。Go 的静态编译很香但处理正则规则、动态加载自定义规则这类场景Python 的灵活性明显更高团队其他人接手时也几乎没有学习成本。依赖方面我没有直接subprocess调 git 命令而是用了一个 libgit2 的 Python 封装库。为什么这么选刚开始我确实是用 subprocess 跑的git status --porcelain和git diff --numstat简单直接。但很快发现三个痛点每次调用都启动一个外部进程大仓库里 status/diff 要跑好几轮性能差。想拿到标准格式的 diff 对象比如逐文件的行增删量要解析文本脆。重命名检测逻辑自己用正则实现非常痛苦。用 libgit2 绑定后仓库对象可以直接在内存里打开diff 是比较native的结构体查询文件的增删行数是 API 级别的操作不再碰文本。代价是它读到的语义和 git 命令在某些边角不完全一致这个坑后面专门说。2.3 目录结构与模块分工项目整体结构比大多数同类型脚本要规整这是从早期脚本演变过来的。核心部分长这样cua/ ├── cua/ │ ├── __init__.py │ ├── cli.py # 命令行入口参数解析与命令分发 │ ├── scanner.py # 仓库信息采集产出变更事实 │ ├── analyzer.py # 规则引擎加载规则并执行判定 │ ├── reporter.py # 输出层终端人类版 JSON 机器版 │ └── rules/ │ ├── __init__.py │ ├── debug_residue.py │ ├── conflict_marker.py │ ├── huge_change.py │ └── empty_source.py ├── tests/ │ ├── conftest.py │ ├── test_scanner.py │ ├── test_rules.py │ └── test_cli.py ├── pyproject.toml └── README.mdcli 层尽量薄只解析参数、组装依赖、调用后续方法scanner 层不关心规则只负责稳定地产出变更清单analyzer 层不关心数据从哪来只知道输入是变更事实reporter 层更是纯函数式的喂什么打印什么。分层带来的好处是写规则的人完全不需要了解 git 细节写测试的人也只需要 mock scanner 的输出。3. 核心实现拆解从仓库扫描到规则判定3.1 信息采集层稳定拿到变更事实scanner 的职责可以描述为给定一个仓库路径返回一份结构化的变更列表。每项包含文件路径、变更类型、增删行数、以及变更前后内容的简单文本表示。获取工作区状态的代码大约长这样刻意做了简化def collect_changes(repo_path: str) - list[FileChange]: repo open_repository(repo_path) head repo.head.peel(repo.Tree) # HEAD tree status repo.status(untracked_filesall) for path, flags in status.items(): if flags repo.GIT_STATUS_INDEX_NEW or flags repo.GIT_STATUS_WT_NEW: file_type A elif flags repo.GIT_STATUS_INDEX_DELETED or flags repo.GIT_STATUS_WT_DELETED: file_type D elif flags repo.GIT_STATUS_INDEX_RENAMED or flags repo.GIT_STATUS_WT_RENAMED: file_type R else: file_type M diff repo.diff(head, repo.index, paths[path]) additions deletions 0 for patch in diff: for hunk in patch.hunks: additions hunk.line_stats[0] deletions hunk.line_stats[1] changes.append(FileChange(pathpath, typefile_type, additionsadditions, deletionsdeletions)) return changes这段代码有个细节值得说明diff的参数顺序是repo.diff(old_tree, new_tree)一开始我传反了所有文件的增删行数都是反的后来加单元测试才抓到。3.2 规则引擎层把人看一眼变成机器判断规则引擎的设计逻辑是一个规则就是一个函数输入是文件变更描述输出是若干 Finding 对象。Finding 包含文件路径、行号、严重级别、问题描述和建议。以调试残留检测为例不同语言要查的东西完全不一样RESIDUE_PATTERNS { py: [pdb.set_trace(), breakpoint(), print(], js: [console.log, debugger, console.debug], ts: [console.log, debugger, console.debug], go: [fmt.Println, log.Println], } def check_debug_residue(file_change: FileChange) - list[Finding]: findings [] if file_change.type ! M: return findings ext file_change.path.rsplit(., 1)[-1] patterns RESIDUE_PATTERNS.get(ext, []) if not patterns: return findings for line_no, line in file_change.iter_added_lines(): stripped line.strip() if any(stripped.startswith(p) or f{p}; in stripped for p in patterns): findings.append( Finding( pathfile_change.path, lineline_no, severitywarning, messagef疑似调试残留: {stripped[:50]} ) ) return findings有几个细节是踩过坑才想明白的只查新增的行不查删除的行。一个文件里历史遗留的console.log不该因为这次改动了其他行就被提示。print(在 Python 里很容易误报所以加了前置条件只在.py文件里查而且要求出现在新增行否则任何一次临时输出都会成为噪音。规则必须返回行号范围这决定了用户收到提示后能不能一秒定位到问题。3.3 报告生成层人读与机器读分离报告层把 Finding 聚合成三块变更统计总文件数、增删总行数、模块聚合按顶层目录拆分、规则问题列表。终端版用树形结构输出文件多时自动折叠JSON 版则只输出结构化数据供 CI 脚本或聊天工具调用。我坚持让人读版富含上下文、机器版绝对纯净是因为两种消费场景完全不同。人在终端看需要哪个文件、什么问题一眼扫到CI 解析时只认字段多一个空格都是噪音。3.4 可插拔规则的扩展机制cua 支持用户在项目里放一个cua_rules.py里面只要实现register_rules(engine)函数引擎就会加载里面的规则。核心机制是用 Python 的命名约定动态加载模块再调用规则函数def load_custom_rules(engine, repo_path: str) - None: rule_file Path(repo_path) / cua_rules.py if not rule_file.exists(): return module importlib.util.spec_from_file_location(cua_custom, rule_file) mod importlib.util.module_from_spec(module) module.loader.exec_module(mod) mod.register_rules(engine)这个设计等于把规则系统开放给了使用者。有些团队希望强制检查需求单号是否出现在提交说明里有些团队希望检测 TODO 注释是否带负责人这些需求不需要改工具本身只要写一小段规则注册进去。工具因此变得像一把瑞士军刀而不是螺丝刀。4. 踩坑记录从能跑到靠谱的四个转折点4.1 第一坑两套 diff 语义不一致前面提到我用了 libgit2 的绑定最初在本地几个小仓库测试一切正常直到某次在一个带 fork 的仓库里跑发现cua 报的增删行数和git diff --stat完全对不上。排查过程是这样的我先写了一个最小脚本只打印头一个文件的 diff 数据和 git 命令逐行对比。检查发现 libgit2 默认 diff 配置里忽略了空文件结尾的换行差异而 git CLI 默认会把它算作一行变更。说白了一个换行符在文件末尾的增删底层库和命令行工具的处理策略不同。后来查文档确认libgit2 的 diff 选项里有include_unmodified、ignore_whitespace等一堆配置默认值跟 git CLI 并不完全一致。我并没有强行对齐两边而是在 cua 的规则里避开了绝对行数这种强校验统一用相对占比和是否存在变更来判断宁可严谨性低一点也要保证跨环境表现稳定。4.2 第二坑非 UTF-8 编码与中文路径真实世界的仓库比教科书残酷多了。有 Windows 上用的 GBK 编码源码有文件名里带着空格和中文的目录还有直接git diff时被转成八进制转义的路径。当时的表现是cua 在某个老项目上输出一堆乱码规则检查倒是正常但报告没法看。查下来有两层问题第一层是 git 自身把非 ASCII 路径转义了需要在配置里显式关闭core.quotePath第二层是读取文件内容时用默认 UTF-8 解码失败。解决方式是在 scanner 里加了一层文件类型与编码探测对常见源码类型做 UTF-8 优先解码失败就回退到 GBK 或 latin-1并且对路径本身统一使用 unicode 规范化。这个坑没有彻底根治的办法只能在数据入口处尽可能防御。4.3 第三坑大仓库性能滑坡一开始 cua 只在我自己的几个中小仓库里跑快得没感觉。直到有人把它放到一个非常大的 monorepo 仓库反馈跑一次要半分钟根本不想用。性能剖析显示瓶颈在规则引擎部分每个文件都要重新读内容和逐行扫描仓库一大了就几何级数增长。优化方向有两个第一个是给所有规则都加了一个隐藏的scope过滤只检查与本次变更相关的文件第二个是把规则引擎里的逐文件处理改成多进程并行Python 的concurrent.futures那套包一层就行。实测下来单次运行从 30 秒降到 3 秒虽然仍然谈不上毫秒级但对提交前手动执行一次的使用场景已经足够。这里我的心得是分析类工具不要一开始就优化先让数据结构和接口稳定再针对真实负载做瓶颈定位过早优化容易把架构搞复杂。4.4 第四坑误报率才是工具的生死线这是我个人觉得最重要的一课。工具上线没几天同事开始在我面前抱怨cua 怎么什么都报详细一问原来是冲突标记规则把文档里正常出现的也当成冲突了。误报的危害比漏报更大。漏报顶多是没拦住问题误报则是狼来了用户几次之后就不再信任任何提示整个工具形同虚设。针对这个问题我把规则引擎改成区分三种级别error必须处理、warning建议处理、info仅供参考。并且给每条规则添加了上下文校验比如冲突标记规则只有在同一次变更中既出现了又出现了才报 error单独出现就直接忽略。同时支持项目级白名单文件理论上规则输出任何一条 finding只要在配置里忽略下次就不再出现。从产品角度说分析工具的核心不是发现问题的数量而是发现的问题里有多少值得用户花时间看。5. 收尾工程测试、分发与可持续维护5.1 测试用临时仓库做集成测试命令行工具最容易翻车的地方不是逻辑而是对真实仓库的假设。为了测试 scanner我在测试目录里动态创建了一个临时 Git 仓库提交几个文件再在工作区做增删改操作然后跑 cua 检查结果。这个 fixture 用测试框架自带的临时目录能力生成测试结束自动清理既不会污染开发目录又能覆盖大部分真实场景。规则测试就更容易了每个规则对应一组纯函数输入输出传入构造好的文件变更对象断言 finding 的数量和级别。这类测试是白盒的速度极快适合本地频繁执行。5.2 打包让cua在终端直接可用打包方面我没有做什么复杂的东西就是标准 Python 包配置。在pyproject.toml里声明了[project.scripts] cua cua.cli:main这样用包管理工具安装之后终端直接多了一个cua命令不需要python -m cua。对使用者来说装完就能敲命令是工具门面的第一印象。如果打包完还要手动配 alias 才能省事那这工具基本八成活不过试用期。我在本地用一个全新的虚拟环境验证了完整流程构建、安装、在空目录里跑cua --help、在真实仓库里跑一次完整检查确认--json输出能被jq正常解析才算收工。5.3 维护原则少加功能多调规则cua 上线后收到过一大串功能请求支持 SVN、支持数据库 schema 比对、生成 HTML 报告、接入某个热门的聊天机器人……大部分我都婉拒了。这倒不是懒而是意识到分析器这类工具一旦开始追功能就会迅速变得平庸。我的维护原则只有两条核心链路尽量不变。采集、分析、报告三层的接口保持稳定新需求优先做成规则或插件而不是改主流程。规则宁可少不可滥。每加一条新规则前先问自己这条规则判断的问题是否 90% 的团队都需要误报率能不能控制在 5% 以下如果答案不确定就搁置。这个原则也决定了 cua 和其他同类工具的区别它不是一个庞大的框架而是一个能自己长出来的、贴近团队习惯的变更守门员。最后说一点个人体会。维护 cua 的过程中我最大的收获不是掌握了 libgit2 的用法也不只是把规则引擎设计得多么优雅而是明白了工具的特点是从真实痛点里收敛出来的。最初我也想过把它做成什么下一代智能变更助手加各种眩目的功能后来发现用户需要的只是提交前快速知道改了哪些文件、有没有明显问题这样朴素的东西。如果你也想写类似的工具我的建议是从一个非常具体的场景开始比如固定每天下班前跑一次、只输出五行摘要。先让它在你的工作流里活下来再去想怎么扩展。工具一旦没人用写得再漂亮都是白费。