有用的提交应当回答仓库行为发生了什么变化。它包含实现、能够说明该行为的验证,以及使用变化所需的文档。这里的“小”指修改理由有明确边界,而不是行数越少越好。例如,修正免运费门槛,需要同时修改计算函数和对应的回归断言;旁边正在编写的无关笔记应当继续留在工作区。把这些边界说明白,之后的读者才知道哪些变化必须一起存在。
暂存区决定这次留下的检查点
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,再把这两个文件的暂存字节复制到另一目录,在那里执行断言,成功后才提交。输出同时记录修复前的失败和暂存快照的成功,因而能区分“测试存在”与“测试确实运行并通过”这两件事。
最终状态只剩 notes.txt 的无关未提交修改;提交中的笔记仍保持原来的文字。这证明选择性暂存没有吞掉另一项工作的内容。实验覆盖一组完整文件的提交边界,没有模拟所有部分暂存组合,也没有执行真实的 revert 或 bisect 会话。临时目录结束后自动清理,当前业务仓库的历史不会被这段示例修改。
留下差异之外仍然需要的理由
“让免运费门槛包含临界值”这样的主题能够直接说明行为变化。正文可以补充原先观察到的错误、期望契约和实际运行的验证,避免只写“修复问题”让后来的人重新猜测。不能因为提交中新增了测试文件,就声称测试已经通过;应说明验证的是哪个快照。发布一组提交前,既要读合并后的完整差异,也要看每个检查点:最终功能要成立,中间步骤也应当如实表达它是怎样逐渐变得可用的。
来源
- Git: git-add,暂存区如何记录选中的内容,已核验 2026-09-13。
- Git: git-bisect,可测试版本与跳过提交的语义,已核验 2026-09-13。
- Git: git-revert,反向提交及其冲突处理,已核验 2026-09-13。