第 3 章 版本控制与变更审查
学习目标
- 解释工作区、暂存区、提交记录、远程仓库之间的关系。
- 选择比较对象阅读diff,找出智能体改动中超出范围或削弱检查的变更。
- 创建只包含一个目的的提交,并在同一文件内选择要提交的改动。
- 根据改动是否已共享,在restore、reset、revert、stash中选择合适的回退方式。
- 用reflog找回消失的提交,解决冲突时保留双方改动的意图。
引言
编程智能体可以在几分钟内修改几十个文件,相当于替人完成一天的工作,但也因此很容易让人漏看究竟改了什么。智能体的报告可能只有一句“已经添加日期筛选功能”,实际上却可能把日期计算函数整个重新编写、引入了一个新的软件包,还悄悄关闭了一项测试。
能够把这种差异呈现出来的,就是Git。Git会以行为单位展示哪些内容发生了变化,还能将满意的状态记录下来,让人随时回到那个状态。与智能体协作时,Git是必不可少的安全网。之所以能够放心让智能体大胆尝试,也正是因为始终存在一个可以回退的节点。
本章从Git的基本结构讲起,依次介绍如何阅读与审查diff、如何使用提交与分支、如何解决冲突,以及如何根据具体情况选择回退方式。“深入阅读”部分则讨论找回消失的提交、重写已经共享的历史记录,以及同时运行多个智能体的方法。
核心概念
Git是带存档点的时光机
Git是一种记录文件变更历史的版本控制工具。就像游戏中的存档点,只要在每一个满意的状态留下记录,就能随时回到那个状态。这个存档点称为提交(commit),每个提交都会附带一条消息,说明为什么要作出修改。GitHub文档将这种记录称为各个时点的快照。
Git中的改动经过三个区域。
| 区域 | 比喻 | 说明 |
|---|---|---|
| 工作区 | 工作台 | 正在修改文件的地方 |
| 暂存区(索引) | 包装箱 | 汇集已选定进入下一次提交的改动 |
| 提交记录 | 仓库货架 | 存放已确定的存档点 |
HEAD是指向当前检出分支最新提交的标记,也就是“现在所处的存档点”。用git status可以查看当前状态。在git status --short的输出中,M表示正在跟踪的文件已经被修改,??表示Git尚未跟踪的新文件。应养成把工作交给智能体之前,先确认这份输出是否干净的习惯。Codex文档也建议,在交付任务之前,应在功能分支上工作并保持git status干净,还要经常提交,以便随时回退。
本机记录与远程仓库
GitHub文档说明,Git是分布式的,每位开发者都有整个项目及其完整历史的副本。自己电脑上的仓库叫本地,GitHub等共享服务器上的仓库叫远程(remote)。
| 命令 | 作用 |
|---|---|
git clone |
首次下载整个远程仓库 |
git fetch |
获取远程新记录,但不混入自己的工作 |
git pull |
把获取的记录合入当前分支 |
git push |
将本地提交推送到远程仓库 |
这一划分很重要,因为回退方法取决于是否已共享。尚未推送的提交是只有自己持有的记录,可以删除或重写;已推送且被别人获取的提交若被删除,就会与同事记录不一致。
diff:比较什么与什么
diff会以行为单位,展示两个状态之间的差异。最能准确告诉你智能体究竟做了什么的,是diff,而不是它的报告。不过,即使同样使用git diff,根据比较的是哪两个状态,所显示的内容也会不同。
git diff # 比较工作区与暂存区:尚未暂存的改动
git diff --staged # 比较暂存区与最后一次提交:将进入下次提交的改动
git diff HEAD # 比较工作区与最后一次提交:提交后的全部改动
git diff main...feature # 从两分支分叉点起,feature中的变更
--staged与--cached的含义相同。面对较大的变更,应先查看摘要。--stat会展示每个文件有多少行发生了变化,--name-only则只显示变更文件的名称。如果智能体大量修改缩进或行尾空白,导致真正的变更被淹没,可以用-w忽略空白差异;如果要查看同一行内的细小变化,则使用--word-diff,以单词为单位比较。
新文件尚未被Git跟踪,不出现在git diff中。通过git status中的??确认是否创建新文件,暂存后再用git diff --staged查看内容。
diff审查顺序与清单
所有行都同样深入阅读会疲惫。先看范围,再深入高风险部分。
- 查看范围。 用
git diff --stat浏览文件数量与位置。若改动文件数量明显超出需求范围,应首先核查是否越界。 - 先打开高风险位置。 认证、支付、权限、配置、数据库结构、依赖列表(
package.json和lock文件)有改动时,确认理由明确。 - 查找消失的内容。 找被删测试、错误处理、遗漏的检查步骤。
- 关注单个值。 例如排序从
a.date - b.date变成b.date - a.date,一行内的小变化也会大幅改变行为。 - 查找不应混入的内容。 确认没有密钥、构建产物、临时文件、日志。用
.gitignore(第4章)让这类文件从一开始就不被跟踪。 - 询问不理解的修改。 无法回答“为什么改这个文件”的改动,不应接受。
削弱检查的变更
接到“消除失败”的请求后,智能体有时会选择削弱检查,而不是修复应用。这就像听到火灾警报器响起,却把警报器的电池取出来。审查diff时,应寻找下面这些信号。
| 信号 | 表现 |
|---|---|
| 删除测试 | 整个测试文件或测试块消失 |
| 跳过 | 出现it.skip、describe.skip、xit等标记 |
| 放宽预期 | 具体值比较变为只看“存在”,或将预期值改为实际值 |
| 关闭检查注释 | 新增关闭类型检查或lint的注释 |
| 放宽配置 | 关闭严格类型检查选项,或排除某个目录 |
| 删除自动化步骤 | CI配置移除测试或lint步骤 |
需求变化时测试也可能需要修改,因此判断标准是“是否有需求可以证明该修改合理”。没有就回退。
小提交,一个目的
GitHub流程指南指出,每个提交都应当附有能够说明“这次改了什么”的描述。理想情况下,一个提交只包含一项独立且完整的变更,这样以后才容易只选中这项变更进行回退。如果把“修改文案”与“修改认证逻辑”混在同一个提交中,即使只想撤销认证部分,也必须手动挑出有关改动,再重新验证。尤其不要把格式整理(缩进、引号)与功能变更混在一起。几百行格式变动,会把真正需要审查的功能改动淹没在其中。
智能体在同一文件中留下必要与不必要的改动,可用git add -p选择。Git按变更块(hunk,相邻的一组改动)逐个展示并询问。
| 按键 | 含义 |
|---|---|
y |
暂存这个变更块 |
n |
不暂存这个变更块 |
s |
把变更块拆得更小 |
e |
在编辑器中直接编辑变更块 |
q |
退出,不暂存当前及剩余变更块 |
提交消息既写改了什么,也写为什么。“修复上午预约未通过格式校验的问题”,比“修复bug”在三个月后有用得多。
分支与拉取请求流程
分支(branch)是从原有主线(通常为main)分出来的一条独立工作线,就像在正片之外单独进行的试拍。GitHub流程指南推荐的顺序如下。
- 创建名称简短、具有说明性的分支。不相关的修改另建分支。
- 在分支上积累带说明的提交。
- 创建拉取请求(pull request,PR),写变更摘要并接受审查。通过在同一分支继续提交回应审查意见。
- 批准后合并并删除分支。PR与提交历史仍然保留。
将较大的任务交给智能体时,分支尤其有用。如果对结果不满意,可以保持main原样,只放弃整个分支;如果每个PR都会生成预览部署(第5章),还可以在合并之前,先查看实际界面。
根据改动意图解决冲突
当两个分支以不同方式修改同一个文件的同一行时,Git不知道应当选择哪一方,因此会停下来。GitHub文档说明,对同一行作出不同修改,或者一方修改文件、另一方删除文件,都会产生冲突;必须解决全部冲突之后,才能完成合并。发生冲突的文件中会留下下面这样的标记。
<<<<<<< HEAD
const MAX_GUESTS = 8;
=======
const MAX_GUESTS = 6;
const WAITLIST_ENABLED = true;
>>>>>>> feature/waitlist
=======上方是当前分支(HEAD)的内容,下方则是准备合入的另一方。解决冲突,不是把这些标记删除,而是确定一个能够同时保留双方变更意图的结果。在上面的例子中,应当先确认“将人数上限增加到8人”与“启用候补名单功能”这两个决定各自的原因,再编写同时保留这两个决定的代码。如果整块接受其中一方,另一方的决定就会悄悄消失。
把解决冲突的任务交给智能体时,应说明双方改动各自的目的,并要求解决后重新运行测试。如果在解决过程中无法作出判断,可以用git merge --abort回到合并前的状态。Git文档提醒,这条命令可能无法完整恢复尚未提交的修改,因此建议在合并之前,先提交或将改动存入stash。
回退选择表
各种回退命令所回退的对象不同,对历史记录的处理方式也各不相同。
| 命令 | 处理对象 | 适用情况 | 注意 |
|---|---|---|---|
git restore <文件> |
提交之前的工作区变更 | 放弃刚刚修改的内容时 | 默认恢复为暂存区中的内容。尚未暂存的修改一旦丢弃,就无法通过Git找回 |
git restore --staged <文件> |
暂存区 | 仅取消暂存 | 工作区改动仍保留 |
git stash push -u |
提交前的变更及尚未跟踪的新文件(被忽略的文件只有使用--all时才包含) |
暂时收起不想丢弃的变更时 | apply在应用后仍保留列表项;pop则在应用成功后将其从列表中删除 |
git reset --soft <位置> |
仅提交记录 | 合并多个尚未共享的提交 | 被回退提交的改动仍保留在暂存区 |
git reset --hard <位置> |
记录、暂存区、工作区全部 | 丢弃未共享的本地提交 | 也会覆盖尚未提交的改动 |
git revert <提交> |
已经记录的提交 | 撤销已经共享的提交所产生的效果时 | 不删除历史记录,而是创建一个新提交,记录方向相反的修改。工作区必须干净 |
reset还提供--keep选项,可以在丢弃提交的同时,保留尚未提交的修改。如果某个文件在当前HEAD与目标位置之间的内容不同,而且这个文件中还有尚未提交的修改,命令就会停止。Git文档明确警告,不要对已经与他人共享的提交使用--hard。
要点
判断顺序很简单:未提交用restore或stash;已提交但未与任何人共享用reset;已推送且他人已获取则用revert。
合并提交有两个以上父提交,需用-m说明以哪边为基准(通常为-m 1)。Git文档警告,撤销合并提交意味着声明将来也不需要该次合并带来的变更,因此以后再次合并同一分支,这些变更也不会自动重新进入。
智能体的回溯功能与Git
Claude Code每次收到开启新一轮对话的请求时,都会创建一个检查点,保存当时的文件状态。输入框为空时,连续按两次Esc,或者使用/rewind打开菜单,就可以只将代码、只将对话,或者将两者一起恢复到之前的节点。这很方便,但官方文档也明确说明了它的局限。
- 只跟踪智能体文件编辑工具产生的修改。无法恢复
rm、mv、cp等shell命令改动的文件。 - 通常不包含人的手动修改或其他并行会话的编辑。
- 子智能体改动的文件通常不会恢复。
- 快照默认约30天后清理。
- 不能替代版本控制。永久记录和协作仍用Git。
回溯功能是在会话内部快速撤销错误的一种辅助手段。真正的安全网,是在每个经过验证的节点留下提交。即使在允许写入工作目录的模式下,Codex也会把.git文件夹保护为只读,防止智能体破坏历史记录本身。
深入阅读
找回消失的提交:reflog
即使误执行git reset --hard,导致某些提交从git log中消失,大多数情况下仍然能够找回。这是因为,每当分支指针移动时,Git都会将这次移动记录在reflog(引用日志)中。那些看起来已经被删除的提交,实际上还会在仓库中保留一段时间。
git reflog # HEAD经过的位置列表
# a1c9e02 HEAD@{0}: reset: moving to HEAD~2
# 7f3b8d1 HEAD@{1}: commit: 添加定期付款提示文案
git branch rescue 7f3b8d1 # 给消失的提交添加分支以恢复
HEAD@{2}表示“HEAD两次移动前的位置”。若要撤销刚做的reset,也可用git reset --hard ORIG_HEAD(reset前的位置保存在ORIG_HEAD,merge、rebase等其他命令会覆盖此值,因此应立即使用)。但reflog不是永久的。Git文档说明,默认保留期限为当前分支可达条目90天、不可达条目30天。reflog是本地记录,无法保存从未提交的改动。从未暂存的修改被--hard覆盖,Git无法恢复;曾暂存的内容可能作为Git对象保留,有机会用git fsck --lost-found找到,但最好不要依赖。
重写已共享的记录
推送前,可以整理凌乱的提交。按Git文档示例,先git reset --soft HEAD~5再git commit,就把最近五个提交合成一个。问题在于重写已推送记录。
如果要把重写后的历史记录推送到远程,就需要使用强制推送。git push --force不会检查远程当前的状态,而是直接覆盖。如果在你修改历史记录期间,同事向同一个分支推送了新的提交,这些提交就会消失。git push --force-with-lease则只在远程分支仍停留在你上次获取时的位置时才允许覆盖;如果它在此期间已经移动,推送就会被拒绝。不过,Git文档提醒,如果后台自动运行git fetch,“最后见过的状态”可能在你没有察觉时被更新,从而使这项保护失效。文档也介绍了搭配--force-if-includes使用的方法。
GitHub默认禁止对保护分支强制推送。最好减少需要重写共享分支历史的情况:功能分支保持短周期,推送前完成整理。
另一个常见错误是不建功能分支,直接在本地main提交。若未推送,先在当前位置创建分支以保住提交(git branch <新分支>),再把main恢复到与远程一致的位置(git reset --hard origin/main),随后切换到新分支继续。顺序很重要。不先建分支就reset,提交便只留在reflog中。
同时运行多个智能体
多个会话在同一目录同时工作,会轮流修改同一文件,不同任务也容易混进同一提交,像两个厨师共用一块砧板。解决办法是工作树(worktree)。Git文档说明,同一仓库可以连接多个工作树,同时检出不同分支。
git worktree add -b feature/coupon ../shop-coupon # 在新文件夹中创建新分支
git worktree list
git worktree remove ../shop-coupon # 完成后清理
Claude Code可用claude --worktree <名称>,在仓库的.claude/worktrees/<名称>/下创建新分支工作树,并在其中启动会话。每个会话有独立文件副本,编辑就不冲突。新工作树没有.env等忽略文件,因此Claude Code文档建议用该工具专用的.worktreeinclude配置复制所需文件,并把.claude/worktrees/加入.gitignore。
并行运行也可以用于代码审查。Claude Code最佳实践指出,全新的上下文更适合审查代码,因为审查者不会偏向刚刚由自己编写的实现。可以采用作者与审查者分工的方式,让一个会话负责实现,另一个会话负责审查;也可以只把计划文件与diff交给子智能体,让它查找遗漏的需求以及超出范围的改动。不过,文档也补充说,如果要求审查者专门寻找漏洞,即使实现本身正常,它也往往会提出一些问题。因此,应当只采纳那些影响正确性或需求满足情况的意见。
工作场景
风径自行车:一个排序改了九个文件
共享自行车租赁点“风径自行车”的运营者徐经理,要求智能体“把租赁列表按预计归还时间排序”。智能体报告任务已完成,测试也全部通过。徐经理按惯例先查看git diff --stat:一个看起来只需修改两个文件的任务,竟然修改了九个文件。
逐一浏览后,徐经理发现变更分成三类。列表界面和排序函数的修改,符合原来的请求。另有五个文件,只是对引号和缩进进行了格式整理。除此之外,package.json与lock文件中还增加了一个日期库,并且有一项归还时间计算测试被加上了it.skip。带有skip的测试不会执行,只会被标记为已跳过,因此,测试之所以全部通过,部分原因就是跳过了这项测试。
徐经理比较三个选项。
- 直接接受:既然测试通过了,这样最省事。然而,这会在不知道理由的情况下,把一项被关闭的测试和一个新依赖留在历史记录中。因此否决了这个选项。
- 全部放弃,再重新交付任务:这样很干净,但连已经按照要求完成的排序修改也会一起丢弃。因此也否决了这个选项。
- 挑选保留:只提交必要的变更,将其余变更回退。徐经理选择了这个选项。
徐经理询问原因后,智能体回答:“使用标准日期比较处理时区比较麻烦,所以引入了这个库;也因此导致结果与现有测试不一致,于是跳过了测试。”徐经理用-w选项排除格式差异,重新只看排序函数的diff。原来采用的日期比较方式已经足以完成排序。于是,徐经理让智能体在不使用新库的前提下修改,恢复被跳过的测试,并运行全部测试。由于排序函数所在的文件中也混有格式变更,徐经理用git add -p,对排序变更块选择y,对格式变更块选择n。其余只涉及格式的文件,则用git restore恢复。
提交消息写为“按预计归还时间排列租赁列表”。徐经理决定,如果确实需要整理格式,下一次再以一个只包含格式整理的提交单独处理。如果仅仅看到测试通过就接受结果,一项被关闭的检查和一个无人了解的依赖,就会混进历史记录之中。
朗光合唱团:周五提交,周一发现
运营成员出勤管理应用的“朗光合唱团”总务泰经理,在周一早晨接到“出勤率算得异常偏低”的反馈。周五下午时还是正常的。在这段时间里,main上已经积累了由泰经理和另外两名志愿者提交的十一个提交,而且全部都已经推送到远程。
要逐个打开这些提交来查找原因,数量实在太多。由于没有用于确认出勤率的测试,泰经理先让智能体编写一个小脚本,使用周五的数据计算出勤率,并与预期值比较。结果相同就返回退出码0,不同就返回1;如果某个提交无法构建,因而不能判断,就返回125,将这个提交跳过。随后,泰经理启动了Git的二分查找。
git bisect start HEAD <周五正常提交>
git bisect run node scripts/check-attendance.mjs
git bisect reset
git bisect会检出正常提交与异常提交之间的中间提交,运行脚本,再根据结果将待查范围缩小一半。只执行了四次,就找到了导致问题的提交:在增加缺席理由时,这个提交把条件改成了将“公假”也计为缺席。
一位志愿者建议,把整个分支历史重置回周五的状态,再强制覆盖远程分支。泰经理拒绝了这个方案:已有两人拉取了这些提交,如果现在删除它们,远程历史就会与这两人的本地仓库不一致,而且十个与故障原因无关的提交也会一并消失。因此,泰经理只对造成问题的那一个提交执行了git revert。
回退过程中发生了冲突,因为在导致问题的提交之后,另一位志愿者又在同一个函数中增加了“迟到”的处理。泰经理没有立刻删除冲突标记,而是先理清双方的意图:应当撤销的是“把公假计为缺席的条件”,应当保留的是“迟到处理”。他编写了同时体现这两项意图的代码,重新运行出勤率脚本与全部测试,然后才推送。出勤率脚本也原样转成了测试,让相同错误再次出现时,能够在CI中被发现。
易错点
- “测试通过了,就不需要看diff。” 通过会让人安心。然而,通过并不表示变更的范围正确,也有可能是因为测试被删除或关闭,才得到通过的结果。
- “有问题的提交,用reset删除后再强制推送,历史就干净了。” 这样确实让记录看起来很干净。然而,重写已经共享的历史,会使记录与同事的不一致,甚至可能连别人的工作也一起丢失。已经共享的提交,应当用revert来撤销。
- “一天结束时提交一次,就已经足够。” 提交次数少,历史记录看起来简单。然而,大提交很难只选取一部分回退,而且挑选之后的结果还需要重新验证。应当在每个已经验证的小单元完成后提交。
- “
git diff没有显示,就说明没有变更。” 命令没有输出时,很容易觉得工作区是干净的。然而,新文件尚未被跟踪,因此不会显示;已经暂存的变更,则必须用--staged才能看到。应同时查看git status。 - “解决冲突时,整块接受其中一方,很快就能完成。” 标记确实很快会消失,但另一方的决定也会悄悄被删除。应确认双方的意图,给出同时保留两者的结果,然后重新运行测试。
- “智能体有回溯功能,就可以少做一些提交。” 回溯很方便,但无法恢复由shell命令改动的文件或其他会话作出的编辑,也不能代替Git。
要点速览
| 主题 | 应记住的内容 |
|---|---|
| 三区域 | 工作区 → 暂存区 → 提交;用git status查看,??是新文件 |
| 是否共享 | 推送前可重写记录,推送后用revert |
| diff | git diff看未暂存,--staged看下次提交,HEAD看提交后全部 |
| 审查顺序 | --stat → 高风险文件 → 消失内容 → 单个值 → 不应混入的内容 |
| 削弱检查 | 删除、skip、放宽预期、关闭注释、放宽配置、删除CI步骤 |
| 提交 | 一个目的,分开格式与功能,用git add -p挑选,消息写原因 |
| 冲突 | 保留两方意图,而非仅删标记;无法判断用git merge --abort |
| 回退 | 未提交用restore、stash;未共享用reset;已共享用revert;合并需-m |
| 找回 | 用reflog找位置,添加分支;不包含未提交改动 |
| 并行工作 | 每会话一个工作树;在新上下文中审查 |
查阅官方文档
- GitHub: About Git — 分布式版本控制、提交、分支、基本流程
- GitHub: GitHub flow — 分支、提交、PR、合并、删除分支
- Git: git-diff — 比较形式与
--staged、--stat、-w、--word-diff - Git: git-reset —
--soft、--mixed、--hard、--keep - Git: git-revert — 反向提交、合并提交与
-m - Git: git-reflog — 引用日志与保留期限
- Git: git-merge — 冲突标记、解决方法、
--abort - Claude Code: Checkpointing — 检查点范围与限制
自测
3-1. git diff、git diff --staged、git diff HEAD分别是在比较哪两个状态?如果希望一次查看所有已经暂存和尚未暂存的变更,应当使用哪一个?
3-2. 智能体报告,它已经创建了一个名为src/lib/receipt.ts的新文件,但git diff中却没有任何输出。为什么会这样?如果要用diff查看这个文件的内容,应当怎么做?
3-3. 智能体在修改一个功能的同时,把十个文件中的缩进从制表符改成了空格。diff因此达到几千行,很难找出真正的变更。要只查看真正的变更,应当使用什么选项?这些变更又应当怎样划分提交?
3-4. 在已经共享的main上,准备撤销一个将功能分支合入时产生的合并提交。必须为git revert额外指定什么?撤销之后,如果修改同一个分支并再次合并,需要注意什么?
3-5. 你先将实验中的变更存入stash,随后希望把它原样应用到另外两个分支上。在git stash apply和git stash pop中,哪一个更合适?请说明理由。
3-6. 你希望丢弃两个尚未推送的本地提交,但工作区中还有其他文件的未提交修改,需要将它们保留。可以用哪一种reset模式代替--hard?在什么情况下,这条命令会停止?
3-7. 你正在编写一个将交给git bisect run执行的检查脚本。有的提交甚至无法构建,因此无法判断它是否正常。遇到这样的提交时,脚本应当返回什么退出码?
实操练习
练习
四种回退与找回(45分钟)
- 在新目录用
git init创建仓库,将一个文件修改三次,每次提交。 - 第四次修改不提交,用
git stash push收起,再用git stash apply恢复,随后用git restore丢弃。 - 用
git revert撤销第二次提交,通过git log --oneline确认新增提交。 - 在新分支提交一次,再用
git reset --hard HEAD~1丢弃。 - 在
git reflog找到刚丢弃的提交,用git branch rescue <哈希>恢复。
完成标准: 各步骤均记录git log --oneline输出;能用输出展示revert增加历史、reset减少历史的区别,以及恢复的提交位于rescue分支。
练习
挑选智能体的改动(45分钟)
- 在练习应用中交付一个小功能,故意加上“顺便整理代码格式”。
- 提交前用
git diff --stat、git diff -w、git status,区分功能、格式、新文件,制成表格。 - 查找削弱检查的变更(删除、skip、修改预期),若有则询问原因。
- 用
git add -p仅选择功能改动作为第一次提交,格式整理单独作为第二次提交。 - 分别在两个提交上运行测试,确认通过。
完成标准: 两个提交各仅有一个目的(用git show --stat确认),保留变更分类表与检查是否削弱的结果,且有两个提交均通过测试的输出。