第 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审查顺序与清单

所有行都同样深入阅读会疲惫。先看范围,再深入高风险部分。

  1. 查看范围。 用git diff --stat浏览文件数量与位置。若改动文件数量明显超出需求范围,应首先核查是否越界。
  2. 先打开高风险位置。 认证、支付、权限、配置、数据库结构、依赖列表(package.json和lock文件)有改动时,确认理由明确。
  3. 查找消失的内容。 找被删测试、错误处理、遗漏的检查步骤。
  4. 关注单个值。 例如排序从a.date - b.date变成b.date - a.date,一行内的小变化也会大幅改变行为。
  5. 查找不应混入的内容。 确认没有密钥、构建产物、临时文件、日志。用.gitignore(第4章)让这类文件从一开始就不被跟踪。
  6. 询问不理解的修改。 无法回答“为什么改这个文件”的改动,不应接受。

削弱检查的变更

接到“消除失败”的请求后,智能体有时会选择削弱检查,而不是修复应用。这就像听到火灾警报器响起,却把警报器的电池取出来。审查diff时,应寻找下面这些信号。

信号 表现
删除测试 整个测试文件或测试块消失
跳过 出现it.skip、describe.skip、xit等标记
放宽预期 具体值比较变为只看“存在”,或将预期值改为实际值
关闭检查注释 新增关闭类型检查或lint的注释
放宽配置 关闭严格类型检查选项,或排除某个目录
删除自动化步骤 CI配置移除测试或lint步骤

需求变化时测试也可能需要修改,因此判断标准是“是否有需求可以证明该修改合理”。没有就回退。

小提交,一个目的

GitHub流程指南指出,每个提交都应当附有能够说明“这次改了什么”的描述。理想情况下,一个提交只包含一项独立且完整的变更,这样以后才容易只选中这项变更进行回退。如果把“修改文案”与“修改认证逻辑”混在同一个提交中,即使只想撤销认证部分,也必须手动挑出有关改动,再重新验证。尤其不要把格式整理(缩进、引号)与功能变更混在一起。几百行格式变动,会把真正需要审查的功能改动淹没在其中。

智能体在同一文件中留下必要与不必要的改动,可用git add -p选择。Git按变更块(hunk,相邻的一组改动)逐个展示并询问。

按键 含义
y 暂存这个变更块
n 不暂存这个变更块
s 把变更块拆得更小
e 在编辑器中直接编辑变更块
q 退出,不暂存当前及剩余变更块

提交消息既写改了什么,也写为什么。“修复上午预约未通过格式校验的问题”,比“修复bug”在三个月后有用得多。

分支与拉取请求流程

分支(branch)是从原有主线(通常为main)分出来的一条独立工作线,就像在正片之外单独进行的试拍。GitHub流程指南推荐的顺序如下。

  1. 创建名称简短、具有说明性的分支。不相关的修改另建分支。
  2. 在分支上积累带说明的提交。
  3. 创建拉取请求(pull request,PR),写变更摘要并接受审查。通过在同一分支继续提交回应审查意见。
  4. 批准后合并并删除分支。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中被发现。

易错点

  1. “测试通过了,就不需要看diff。” 通过会让人安心。然而,通过并不表示变更的范围正确,也有可能是因为测试被删除或关闭,才得到通过的结果。
  2. “有问题的提交,用reset删除后再强制推送,历史就干净了。” 这样确实让记录看起来很干净。然而,重写已经共享的历史,会使记录与同事的不一致,甚至可能连别人的工作也一起丢失。已经共享的提交,应当用revert来撤销。
  3. “一天结束时提交一次,就已经足够。” 提交次数少,历史记录看起来简单。然而,大提交很难只选取一部分回退,而且挑选之后的结果还需要重新验证。应当在每个已经验证的小单元完成后提交。
  4. “git diff没有显示,就说明没有变更。” 命令没有输出时,很容易觉得工作区是干净的。然而,新文件尚未被跟踪,因此不会显示;已经暂存的变更,则必须用--staged才能看到。应同时查看git status。
  5. “解决冲突时,整块接受其中一方,很快就能完成。” 标记确实很快会消失,但另一方的决定也会悄悄被删除。应确认双方的意图,给出同时保留两者的结果,然后重新运行测试。
  6. “智能体有回溯功能,就可以少做一些提交。” 回溯很方便,但无法恢复由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找位置,添加分支;不包含未提交改动
并行工作 每会话一个工作树;在新上下文中审查

查阅官方文档

自测

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分钟)

  1. 在新目录用git init创建仓库,将一个文件修改三次,每次提交。
  2. 第四次修改不提交,用git stash push收起,再用git stash apply恢复,随后用git restore丢弃。
  3. 用git revert撤销第二次提交,通过git log --oneline确认新增提交。
  4. 在新分支提交一次,再用git reset --hard HEAD~1丢弃。
  5. 在git reflog找到刚丢弃的提交,用git branch rescue <哈希>恢复。

完成标准: 各步骤均记录git log --oneline输出;能用输出展示revert增加历史、reset减少历史的区别,以及恢复的提交位于rescue分支。

练习

挑选智能体的改动(45分钟)

  1. 在练习应用中交付一个小功能,故意加上“顺便整理代码格式”。
  2. 提交前用git diff --stat、git diff -w、git status,区分功能、格式、新文件,制成表格。
  3. 查找削弱检查的变更(删除、skip、修改预期),若有则询问原因。
  4. 用git add -p仅选择功能改动作为第一次提交,格式整理单独作为第二次提交。
  5. 分别在两个提交上运行测试,确认通过。

完成标准: 两个提交各仅有一个目的(用git show --stat确认),保留变更分类表与检查是否削弱的结果,且有两个提交均通过测试的输出。