小提交应当讲清一个完整变化
有用的提交应当回答仓库行为发生了什么变化。它包含实现、能够说明该行为的验证,以及使用变化所需的文档。这里的“小”指修改理由有明确边界,而不是行数越少越好。例如,修正免运费门槛,需要同时修改计算函数和对应的回归断言;旁边正在编写的无关笔记应当继续留在工作区。把这些边界说明白,之后的读者才知道哪些变化必须一起存在。 暂存区决定这次留下的检查点 Git 区分工作区、暂存区和已经提交的文件树。编辑文件只会改变工作区;执行添加操作,才会把当时的内容放进暂存区。此后继续编辑,不会自动更新已经暂存的版本。因此,在当前目录运行测试成功,并不能证明即将提交的快照也能通过:测试可能依赖尚未暂存的修复、导入或配置。审阅时应看暂存差异,包括新增和删除;部分暂存造成实质差异时,应验证选中的那个快照。 同一个目录里有两件独立工作时,暂存区可以帮助保留边界。只选择当前目的需要的文件或代码块,同时检查它依赖的测试数据、导入、配置和说明是否齐全。只有几行实现,却遗漏必需的数据库结构变更,并不是可用的独立检查点。反过来,一次机械重命名即使涉及很多文件,只要变化理由一致,仍然可以是一个完整事项。文件数量不能替代对依赖关系的判断。 下面的小实验只有一组自包含文件,因此只复制改动文件就足够;真实仓库的暂存快照还必须包含未改动的已跟踪依赖、可执行模式以及必需的子模块。若不完整导出又意外从工作区导入依赖,就破坏了检查原本想建立的隔离,测试通过仍不能证明实际提交可用。 坏掉的样子 混合提交把本来无关的决策绑在一起。回退界面变化时,可能顺带移除藏在同一提交里的可靠性修复。随意按文件拆开也未必更好:如果第一个提交已经调用新接口,第二个提交才定义接口,中间状态就不能运行。应该保留有意义的中间版本;如果一组修改必须按顺序共同审阅,就明确写出依赖,而不是用很多碎片制造可独立交付的假象。 小提交能够减少审阅者需要同时理解的信息,却不保证反向操作没有冲突,更不保证生产回退安全。后续代码可能已经依赖之前的行为,数据库迁移也可能改变 Git 无法恢复的数据。revert 会记录一个新的反向变化,与中间发生的修改仍可能冲突。即使文本合并成功,也要重新检查最终行为及部署条件,不能把命令成功当作业务状态已经恢复。 二分定位同样需要历史版本上可靠的好坏判定。某个中间提交无法编译,不等于目标缺陷就是在这里引入的。跳过无法测试的版本,有时只能得到多个可能候选。保持检查点可运行有助于定位,但提交够小、标题够清楚,都不能修好一个会随机失败的测试,也不能补齐历史环境缺失的依赖。 快速验证 把下面代码保存为 small-commits.py,安装 Git 后执行 python3 small-commits.py。脚本创建临时仓库,隔离全局 Git 配置并使用空 hooks 目录,只在该仓库创建两个本地提交。实验约定:小计低于五十时收取五个单位运费,达到五十就免运费。初始函数错误地把恰好五十也算进了收费区间。 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess import sys with TemporaryDirectory() as directory: repo = Path(directory) / 'repo' repo.mkdir() hooks = Path(directory) / 'empty-hooks' hooks.mkdir() env = {'PATH': os.environ['PATH'], 'LC_ALL': 'C', 'GIT_CONFIG_NOSYSTEM': '1', 'GIT_CONFIG_GLOBAL': os.devnull} def git(*args): return subprocess.check_output([ 'git', '-c', 'user.name=Example', '-c', 'user.email=example@example.invalid', '-c', 'commit.gpgsign=false', '-c', f'core.hooksPath={hooks}', '-c', 'core.autocrlf=false', *args], cwd=repo, env=env, text=True) git('init', '-q', '-b', 'main') (repo / 'price.py').write_text('def shipping(subtotal):\n return 5 if subtotal <= 50 else 0\n') (repo / 'test_price.py').write_text('from price import shipping\nassert shipping(49) == 5\n') (repo / 'notes.txt').write_text('unrelated draft\n') git('add', 'price.py', 'test_price.py', 'notes.txt') git('commit', '-qm', 'Add shipping calculation and its initial check') test = 'from price import shipping\nassert shipping(49) == 5\nassert shipping(50) == 0\n' (repo / 'test_price.py').write_text(test) failed = subprocess.run([sys.executable, '-B', 'test_price.py'], cwd=repo, capture_output=True, text=True) assert failed.returncode != 0 and 'AssertionError' in failed.stderr (repo / 'price.py').write_text('def shipping(subtotal):\n return 5 if subtotal < 50 else 0\n') (repo / 'notes.txt').write_text('unrelated draft, still being edited\n') git('add', 'price.py', 'test_price.py') staged = git('diff', '--cached', '--name-only').splitlines() assert staged == ['price.py', 'test_price.py'] snapshot = Path(directory) / 'index-snapshot' snapshot.mkdir() for name in staged: (snapshot / name).write_text(git('show', ':' + name)) subprocess.run([sys.executable, '-B', 'test_price.py'], cwd=snapshot, check=True) git('commit', '-qm', 'Make the free-shipping threshold inclusive') assert git('status', '--porcelain') == ' M notes.txt\n' assert git('show', 'HEAD:notes.txt') == 'unrelated draft\n' print(json.dumps({'regression_failed_before_fix': True, 'staged_files': staged, 'staged_test_passed': True, 'unrelated_edit_preserved': True})) 新增的边界断言首先会在旧函数上失败。修正之后,脚本仅暂存 price.py 和 test_price.py,再把这两个文件的暂存字节复制到另一目录,在那里执行断言,成功后才提交。输出同时记录修复前的失败和暂存快照的成功,因而能区分“测试存在”与“测试确实运行并通过”这两件事。 ...