Ways to Robot 从零到云端机器人

第三章 · 给时间拍照

Git:让每一次试错都可以退回去

你一定会遇到这样的夜晚:改好了,觉得很满意;第二天早上再看,有一句话变得很怪——可你已经想不起改之前是什么样了。你也一定会遇到这样的犹豫:想试个新功能,又怕把已经能跑的东西弄坏。这一章要给你的不是一张命令表,而是一台时间机器,以及使用它的判断力。

0. 先看地图:这一章要带你去哪

读完这一章,你应该能回答

  • Git 到底在保存什么?为什么说它「存的是整个项目的快照」而不是「改了哪几行」?
  • 工作区、暂存区、版本库这三棵树分别是什么?git status 输出的每一行,对应哪一棵树?
  • 提交连成一张有向图,分支只是一个「贴在某个提交上的可移动标签」——这句话能解释哪些现象?
  • 当你改坏了东西,应该用 git restore、git restore --staged、git commit --amend、git revert 还是 git reset?判断依据是什么?
  • 为什么在共享分支上只能 revert 不能 reset?为什么 push --force 会抹掉别人的工作?
  • 密钥被提交并推送出去之后,第一步该做什么?为什么「删掉那次提交」不是第一步?
  • 没有版本控制,你就无法回答「是哪一次改动让它变坏的」。那 Git 能帮你把这个问题变成一道可执行的流程吗?

学完这一章,你会做这些事

  • 从零建立一个仓库,并写出有意义的提交历史
  • 用分支做实验、用 revert 安全撤销、把项目退回任意一版
  • 亲手解决一次合并冲突,并说清自己为什么保留某一边
  • 写出一份完整的 .gitignore,并知道密钥泄露后的正确应急顺序
  • 把代码推到 GitHub,把它当作备份、协作与自动检查的入口

1. 三个夜里发生的事

在讲任何命令之前,我要先讲三件具体的事。它们都不是我编的,它们是你接下来三个月里一定会遇到的场景。如果你能在这三个场景里认出自己,这一章后面的所有内容就都有了归宿。

1.1 第一件事:你想不起改之前是什么样了

那是一个凌晨一点半。你终于受够了她那句「我理解你的感受,这确实不容易」——太像客服了,一点都不像学姐。你打开 persona-source.txt,在【说话方式】那一段底下加了两行:

【说话方式】
- 简洁优先。默认 70 字以内,能一句说清就别用两句。
- 少说“我理解你的感受”这种万能句,接话要具体。

▲ 这是 qq-bot/persona-source.txt 里【说话方式】那一段的真实结构。它是人设的唯一权威来源。

然后你跑本地试聊,前后对比了七八条回复,满意。你顺手把改动同步到线上,重启了服务,睡了。

第二天中午,群里有个人说「我这次数学考砸了」,OWL 回了一句:「没事,下次考好点就行。」

你盯着这句话看了很久。这句话不恶毒,但它冷。它像一个赶时间的人随口丢下的一句话。你立刻意识到问题——但你说不出问题出在哪里,因为你想不起来昨天那版是什么样了。你加了「简洁优先」,是不是把她「先接情绪」的那一段压掉了?还是你把某个语气词删得太干净了?

你打开 persona-source.txt,看到的只有「现在」。昨天那一版,已经被你覆盖掉了。它不存在于任何地方。

警告这不是一个关于「备份」的问题,虽然它看起来像。真正的损失不是「那份文件没了」——你可以凭记忆重写。真正的损失是你失去了「比较」这个动作。你没有两份可以并排放在一起的东西,所以你的判断只能靠感觉;而靠感觉的判断,在深夜里可靠不了两天。

1.2 第二件事:你想试个新功能,但你不敢

你在 bot/config.json 里看到那四个关键词规则,觉得「这个模式挺好用,我能不能让指令也支持变量」,或者「我想给私聊加一条『超过 400 字直接拒绝输入』的提示语」。

于是你开始改代码。改到一半,你发现 matchCommand 这个函数的结构和你想的不一样,于是你顺手把它的循环也改了;改完你想测试,又发现 config.json 里某条指令的格式也需要跟着调整。

半小时后,机器人不回话了。

现在你面对的局面是:你手上有一个「不能跑的新版本」,硬盘上有一个「不知道被改了多少处的旧状态」,而你最想做的事——「回到半小时前那个能跑的状态」——是你做不到的。你没有那个状态。它从来没有被保存在任何地方。

于是你只能做那件最昂贵的事:把刚才半小时的改动一条一条回想出来,手工撤销。你会在某个时刻撤销多了一行,然后机器人开始报另一个错。凌晨三点,你决定放弃这个新功能。

这个新功能本身可能只值十五分钟。你为它付出的是三个小时,以及一次「我果然不行」的自我怀疑。

1.3 第三件事:你想比较两个数字

你在 HOW-TO-TUNE.md 那张表里看到一行:

| 稳定 vs 灵动 | `llm.temperature` | `0.8` | 调低更稳更暖;调高更活泼但易跑偏 |

▲ qq-bot/HOW-TO-TUNE.md 第三节「除了人设,还有这些风格旋钮」,这是你现在真正该动的那几个字段。

你想验证这句话。你想知道 0.8 和 1.2 到底差多少,因为「更活泼」和「易跑偏」这两个词对你来说还是别人的结论,不是你的经验。序章里的心法三说过:一切都要亲手验证。

于是你开始手动做实验:把 temperature 改成 1.2,保存,重启,问七个固定问题,把七个回答抄到记事本里;然后再改回 0.8,保存,重启,再问七个问题,再抄一遍。

这个实验本身设计得不错。它坏在三个地方:

  • 你抄下来的那十四段回答,和「它们是由哪一版配置产生的」这件事是靠你的记忆连起来的。三天后你翻到记事本,你已经不确定第二组到底是不是 0.8 跑的。
  • 中间你为了对比顺手改了人设里的一个字,于是两组数据其实不是同一个实验条件下的产物,而你当时没意识到。
  • 如果有人问你「那你最后选了哪个值,为什么」,你只能回答「感觉 0.8 好一点」。你手里有数据,但没有可追溯的实验记录。

把这三件事放在一起看,你会发现它们有一个共同的形状:你缺的不是能力,是「过去的某个状态」这件事本身没有被保存成可以取用的东西。

所以我要把这一章的结论,放在最前面说。

Git 解决的不是「代码管理」这件事。代码管理听起来像是给团队用的、给大项目用的、离你很远的词。

Git 真正解决的是:人不敢试验。

当你随时能回到任何一个曾经跑通过的状态,你才敢把 temperature 改成 1.5 试试;你才敢把整段人设的顺序调换看看;你才敢在不知道答案的时候先动手。而「敢不敢试验」,直接决定了你三个月后是一个只有一套作品的人,还是一个试过三十种做法、因此知道哪种做法在什么情况下更好的人。

想一想请你诚实地回想:过去一个月里,你有多少次「算了,别动了,万一坏了我修不好」的念头?把这些次数写下来。我的判断是:这个数字约等于「Git 能替你省下的机会成本」。你同意吗?如果不同意,你觉得我高估了还是低估了?

2. 心智模型:Git 到底在保存什么

这一节是全章最重要的部分。绝大多数人学 Git 失败,不是因为命令记不住,而是因为脑子里那张图是错的。图错了,命令就永远是死记硬背;图对了,很多命令你能自己推出来。

所以请你慢一点读。这一节几乎没有命令。

2.1 第一张图:快照,不是差异

你可能会自然地以为,Git 像一个「改动记录本」:你每改一次,它就记一行「把第 12 行的 0.8 改成了 1.2」。像 Word 的修订模式那样。

不是的。Git 保存的是快照(snapshot):每一次提交,它都记下「此刻整个项目的所有文件长什么样」。一次提交就是一个完整的状态点,不是「相对上一次改了什么」。

「差异模型」的想象(错的)
  初始 ──[改A]──> ──[改B]──> ──[改C]──>  现在
        你手上只有一条改动链,想看任意时刻的样子
        必须从起点把所有改动重放一遍

「快照模型」的真相(对的)
  提交①        提交②        提交③        提交④
  ┌──────┐    ┌──────┐    ┌──────┐    ┌──────┐
  │ 全项目 │    │ 全项目 │    │ 全项目 │    │ 全项目 │
  │ 的状态 │    │ 的状态 │    │ 的状态 │    │ 的状态 │
  └──────┘    └──────┘    └──────┘    └──────┘
  任何一次提交,都可以被直接「取出来」放到你面前

▲ 这是本章的第一张 ASCII 图。真正的区别不是「存什么」,而是「你能不能直接跳到任意一个时刻」。

为什么这件事这么重要?因为它决定了三件你以后每天都会用到的事:

  1. 「回到某个时刻」是一次直接跳转,不是一次逆运算。你不需要知道中间发生过什么,就能直接看到三月十二号下午那版代码。这是「能对比」的前提。
  2. 历史是可以分叉的。因为每个快照都是完整的,你完全可以造出两条不同的时间线,让它们各自往前走,最后再决定要不要把某一段拿回来。
  3. 删除一个提交不会连带毁掉别的东西。(这一点我们到分支那节会讲透。)

要点「快照」这个词还有一层含义:Git 存的是当时的文件内容,而不是「文件的地址」。所以就算你后来把 persona-source.txt 改名成 persona.txt,或者把它挪到另一个目录里,旧快照里的那份内容依然原样存在。这一点会在第七章给你帮大忙——那时你会发现「线上代码更新了、但数据不能丢」是怎样被设计出来的。

2.2 第二张图:三棵树

现在讲第二个模型。你写代码的时候,其实同时存在三个地方,每一处都放着「一份项目的样子」。Git 把这三处叫做三棵树:

   ┌────────────────────────┐
   │  ① 工作区 Working Tree  │  你真正在用编辑器改的那些文件
   │  (硬盘上的文件夹)      │  这里永远是最新的、也可能是最乱的
   └───────────┬────────────┘
               │  git add  →  「这一版,我要定了」
               ▼
   ┌────────────────────────┐
   │  ② 暂存区 Staging Area  │  一个「待提交清单」的抽屉
   │  (也叫 Index)         │  里面装的是你挑好、准备打包的快照
   └───────────┬────────────┘
               │  git commit → 「把抽屉里的东西封成一个时刻」
               ▼
   ┌────────────────────────┐
   │  ③ 版本库 Repository    │  一串不可改写的提交,永久留档
   │  (.git 目录里)        │  你随时可以从这里取出任何一版
   └────────────────────────┘
        三棵树:从「正在改」到「已留档」的三级台阶

▲ 这是本章的第二张 ASCII 图。请把它当成一张地图贴在脑子里:后面每个命令,我都会告诉你它在动这三棵树里的哪一棵。

新手最常跳过的是中间那棵。他会想:「我 add 完马上 commit,中间那层不是多此一举吗?」

不多余。暂存区的存在是为了让你能做一件很重要的事:把「我这次改了什么」和「我这次想提交什么」分开。

举个你一定会遇到的例子。你在调 OWL 的提问频率,顺手做了两件事:一是把人设里【别总问问题】那段加了一句「10 条回复最多问 2 次」,二是你在调这一段的过程中发现代码里有个 limitToSingleQuestion 的开关写错了,也改了。这两件事其实是一件事的两半——它们应该被记在同一个提交里吗?不一定。如果它们是两个独立的问题,你会想把它们拆成两个提交,这样以后回头看历史时能看明白。而「拆开」这个动作,靠的就是暂存区:你 add 一部分,提交;再 add 另一部分,再提交。

技巧一个立刻能用的判据:如果你写提交信息时发现必须用「并且」这个词(修改人设并且修复提问限制),那说明这可能该是两个提交。这不是硬规矩,但它能帮你在最初几周养成把改动打包成有意义单元的习惯。第九章讲代码评审时会回过头来讲它为什么重要。

2.3 第三张图:提交构成一张有向图

现在讲第三个、也是最能解开新手困惑的模型。

每一个提交,除了保存快照,还保存了两样东西:一个属于自己的编号,以及它的父提交是谁。于是所有的提交连成一张图:箭头从子指向父,也就是「我是在谁的基础上长出来的」。

                              ┌──────────┐
                              │ 提交 a1b2 │  ← 每个方框是一个完整的项目快照
                              └────┬─────┘
                                   │ 父节点
                              ┌────▼─────┐
                              │ 提交 c3d4 │
                              └────┬─────┘
                                   │
                              ┌────▼─────┐
                              │ 提交 e5f6 │  ← 最新的提交
                              └────┬─────┘
                                   ▲
                                   │
                          ┌────────┴────────┐
                          │  分支 main      │  ← 分支只是一个「贴纸」
                          └─────────────────┘     贴在某个提交上,可以移动

▲ 这是本章的第三张 ASCII 图。注意箭头方向:从新指向旧。Git 的历史是往回看的。

这句话请你读三遍,它是这一章里性价比最高的一句话:

分支不是一份代码的副本,分支只是一个指向某个提交的可移动标签。

现在我用这一句话去解释四个新手最想不通的现象。每一个你都可以自己先猜一遍再往下看。

  • 为什么切换分支那么快?因为在底层,git switch 并不是「把另一个文件夹的内容拷过来」。它做的是:把指针挪到那个提交上,然后把工作区里的文件改成那个快照的样子。项目小的时候,你感觉不到任何延迟——它不是「拷贝」,是「换一副眼镜」。
  • 为什么删掉一个分支,代码不会丢?因为你删掉的只是一张写着名字的贴纸。那个提交本身还在图里。只要你记得它的编号(或者用 git reflog 找到它),你随时能把贴纸重新贴回去。这就是 1.1 节那个「想不起昨天那版」的问题的解药:昨天那版没有消失,只是它的名字被删了。
  • 为什么会出现「两个分支各走各的」?因为两个贴纸本来就可以从同一个提交出发,各自往前贴。图是可以分叉的。这不是异常,这是设计。
  • 为什么「合并」之后历史会变成一张网?因为合并会产生一个有两个父节点的提交——它同时把两条线的末端收进来。图从此不再是一条直线,而是一张网。

想一想如果「分支 = 贴在提交上的标签」,那么「我在 main 分支上」这句话,严格来说是什么意思?请你用「指针 + 位置」的语言重说一遍。想清楚这一句,你在这一章后面的每一次「我到底在哪个分支上、我的改动去哪了」的困惑,都会自己解开。

2.4 哈希值:那个 40 位的乱码是什么

你很快会看到这样的输出:

a1b2c3d4e5f60718293a4b5c6d7e8f9012345678

这串东西叫提交哈希值(commit hash),也常被叫做 哈希。它的正式名字是 SHA-1,长度 40 个十六进制字符。日常使用中你只需要写前 7 位(比如 a1b2c3d),Git 就能认出它——这就是为什么教程里到处出现 git log --oneline 那种短编号。

它有三个你必须知道的特性:

  1. 它是根据内容算出来的。算它的输入包括:整棵项目的文件树、这次提交的信息、作者、时间,以及父提交的哈希值。内容变一个字符,算出来的结果就完全不同。
  2. 所以它是「不可伪造的身份」。两个内容相同的提交会有相同的哈希;内容不同则一定不同。这也是为什么 Git 敢说「历史可以被验证」——如果你偷偷改了某个旧提交的内容,它以及它后面所有的哈希都会变,一眼就能看出。
  3. 于是哈希也成了「整条链的指纹」。最新那个提交的哈希,隐含地锁定了它之前的所有历史。这是 Git 能安全地做分布式协作的底层原因,也是第四章讲 HTTPS 时你会再见到的同一种思想(那一次是用来证明「你连上的服务器确实是它」)。

警告哈希不是「版本号」。它不能比较大小,也没有顺序。判断「哪个更新」永远要看它在图里的位置(谁是谁的父),不能看哈希的大小。这是一个很常见但很隐蔽的误解,它会让读 git log 的人得出完全错的结论。

2.5 HEAD:你「现在站在哪里」

最后一个概念,也是每天都要用到的:HEAD。

HEAD 是 Git 里的一个特殊指针,它指向「你此刻所在的位置」。绝大多数时候,HEAD 指向一个分支标签(比如 main),意思是「我站在 main 这条线上,我下次提交会长在 main 的末端」。这正是「提交后 main 会自动往前挪一格」的原因——因为提交的动作就是「生成新提交,然后让 HEAD 所指的那个标签挪到新提交上」。

提交前:  main ──► [e5f6]  ← HEAD 指向 main
                     │
提交后:  main ──► [g7h8]  ← HEAD 仍然指向 main
                     │        (是 main 自己挪了一格)
                   [e5f6]

▲ 所谓「提交」,就是「造一个新方框,然后把当前分支标签挪过去」。没有更神秘的东西。

有时候 HEAD 会直接指向一个提交而不是分支,这叫 detached HEAD(游离头指针)。新手常常在某次操作后莫名其妙进入这个状态,然后发现「我提交了,但切回去之后改动不见了」。原因很简单:HEAD 直接指向提交时,新提交没有标签跟着走,所以没人记得它。这时你要做的事是给它贴一个标签(新建分支),或者用 git switch - 回到原来的位置。这个坑我们放到第 5 节的实际操作里再演练一次。

把这一节压缩成五句话,请你背下来:

一、每个提交是一次完整快照,不是一条差异记录;所以任意时刻都能被直接取出来比较。

二、你手下有三棵树:工作区(我在改)、暂存区(我挑好了)、版本库(我封存了)。

三、提交连成有向图,箭头从新指向旧,每个提交记得自己的父节点。

四、分支只是一个可移动的标签,指向图里的某个提交;所以它轻、快、删了也不丢东西。

五、HEAD 指向你此刻在哪,它决定了你的下一次提交会长在图上哪个位置。

3. 第一次使用:用 OWL 项目走一遍完整的提交

现在开始动手。这一节我不会给你一张命令表,而是给你一个任务:给 OWL 加一个 /echo 指令,让它在群里复读你说的话,然后把这件事完整地提交一次。

为什么选这个功能?因为它足够小(一个小改动就能跑通),又足够真实(它要走完「读配置 → 匹配指令 → 生成回复」这条你以后每天都会走的链路)。

3.1 先装 Git,再告诉它你是谁

装 Git 很简单:Windows 上去 git-scm.com 下载安装包,一路下一步;Ubuntu 服务器上一条命令 sudo apt install git。装完在终端里输入 git --version,应该看到类似 git version 2.4x.x 的输出。看到版本号就说明装好了。

然后做一件新手最常跳过、却一定会后悔的事:告诉 Git 你是谁。

git config --global user.name "你的名字"
git config --global user.email "你的邮箱@example.com"

▲ --global 表示「这台机器上所有项目都用这个身份」,配置写在你的用户目录下的 .gitconfig 里,只写一次。

为什么必须有?因为每一次提交都要记录「谁写的、什么时候写的、怎么联系他」。这不是形式主义——请你回到 1.1 节那个场景:三个月后你翻历史,看到某一行是「晚上两点十三分改的」,这本身就是重要信息。而在多人项目里,「这行代码是谁写的」决定了你该去问谁。Git 不做用户系统、不联网验证身份,所以它只能靠你告诉它。你不告诉它,它会拒绝提交,并给你一句非常直白的报错——这不是它脾气坏,是它没法伪造一个作者。

警告这个邮箱会永久写进每一个提交里,而且一旦推到 GitHub 就是公开的。所以请不要填一个你不想被看见的邮箱;如果你在意隐私,GitHub 提供了「不公开邮箱」和「为隐私生成的 noreply 邮箱」这两条路,可以在账户设置里查到。这件事在第七章部署时还会出现一次——那时它关系到的是 SSH 密钥,比邮箱严重得多。

3.2 把 OWL 项目变成仓库

现在走到你的项目目录里:

cd "C:\你的项目目录\qq-bot"
git init

▲ git init(initialize 的缩写)在当前目录下创建一个隐藏的 .git 目录。你的项目内容一个字节都没动。

git init 做的事非常小:它建了一个叫 .git 的文件夹,在里面放上仓库的骨架(配置、空的对象库、空的分支指针)。从这一刻起,这个目录就从「一堆文件」变成了「一个仓库」。

这里有一个你必须知道的区别:Git 的「仓库」就是你项目目录本身,加上里面那个 .git 子目录。所以「把仓库发给别人」和「把项目目录发给别人」是两件不同的事。如果你直接把文件夹压缩发给朋友,.git 会跟着一起过去,历史也一起过去了;如果你只想给他看代码,可以只复制文件,不复制 .git。记住这一点,等你以后复制整个项目做实验时就明白了:复制来的东西是「独立的另一个仓库」,它和你原来的仓库没有任何联系。

3.3 第一次 git status:看懂它说的每一句话

在你还没有提交任何东西之前,git status 会这样告诉你(以下是真实输出结构,文件清单为示意):

$ git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        .gitignore
        README.md
        bot/
        deploy/
        tools/

nothing added to commit but untracked files present (use "git add" to track)

▲ git status 是你在 Git 里最该熟练掌握的命令——它比 git log 更常用。养成「改完就问一次 status」的习惯,可以避免 80% 的新手事故。

请逐行读它,每一行都在回答一个具体问题:

这一行它在说什么对应哪棵树
On branch main你此刻站在 main 这个分支上,也就是 HEAD 指向 main—
No commits yet版本库里一个提交都没有,这是一张白纸版本库(空的)
Untracked files这些文件存在于工作区,但 Git 从来没见过它们,所以「不跟踪」工作区有,另两棵没有
(use "git add ...")Git 在直接告诉你下一步该做什么——它的提示通常就是正确答案—
nothing added to commit暂存区是空的,所以现在提交等于提交一个空快照暂存区(空的)

注意 Untracked 这个词的分量。它不只是「还没提交」,而是「Git 还不认识它,因此不会去管它未来变成什么样」。所以当你在工作区里改了一个 untracked 文件,git status 不会告诉你「它被改了」——因为在一个 Git 不认识的文件上,没有「被改」这个概念。

还有另外几种状态你会陆续见到,这里先给一张对照:

  • Untracked:Git 不认识它(新文件)。
  • Changes not staged for commit:Git 认识它(以前提交过),你改了,但还没 add。
  • Changes to be committed:已经 add,进了暂存区,下次 commit 会带走它。
  • nothing to commit, working tree clean:三棵树一致,你手上没有未保存的改动。

技巧把「working tree clean」当成一个让自己安心的信号。每次要做一个有风险的操作之前,先跑 git status:如果它是 clean,说明你手上没有任何未保存的东西,接下来的操作就算搞砸了,也只是回到上一个提交,损失有限。你的操作恐惧,大半来自「我不知道现在有没有未保存的东西」。

3.4 先写 .gitignore,再 git add

这是很多人顺序搞反的一步,也是第 7 节整节要讲的事的序曲。在你第一次 git add . 之前,先把 .gitignore 写好。理由很实际:一旦某些东西进了历史,清理它比「一开始就不让它进去」贵十倍。

第一步,先看这个项目原来的样子。下面是本书写作时 qq-bot/.gitignore 里的真实内容:

# 敏感文件:不要把 API Key 提交或分享出去
bot/llm.local.json

# 运行时产物
bot/bot.log
bot/history.json
bot/memory.json
*.selftest-backup

# 临时验证产物
_verify/

▲ 这是当时的真实内容,不是示意版。第一行就是整个文件存在的理由。

请不要小看这份短文件。它每一条都在回答一个具体的问题:

  • bot/llm.local.json —— 里面写着你的 API Key 和之前讲的那些读取优先级。它一旦进了仓库,就等于把你的钱包放到了公共场合。
  • bot/bot.log —— 运行日志,每次都变,进了仓库只会让每次提交都带着一大堆噪声。
  • bot/history.json 与 bot/memory.json —— 这是用户数据:谁在什么时候说过什么、你对他的印象。它既不是代码,也不该被公开,还是随时在变的。
  • *.selftest-backup 与 _verify/ —— 各种自测跑出来的临时产物,属于「机器生成的垃圾」。

这四类东西对应着四个通用原则:密钥不进、用户数据不进、机器生成的垃圾不进、每次都变的东西不进。记住这句话,以后换任何一个项目,你都能自己写出这份文件。

但它漏了几样。而且漏掉的每一样都是要命的东西。

3.5 一次真实的疏漏:漏掉的四类东西

请你对照上面那份文件,看看下面这四样为什么它一条都没挡住。它们当时就躺在项目目录里:

漏掉的东西它是什么后果
qq-bot/deploy/server-key.pem登录你那台云服务器的私钥。你每天 ssh -i 用它连上 <你的服务器 IP>,第七章还会用它。拿到它的人可以直接登上你的服务器。OWL 的全部代码、config.json 里的人设与鉴权 token、NapCat 的 QQ 登录态,全部在那一台机器上。
qq-bot/deploy-token.txt一份 32 位的令牌。注意它在 qq-bot/ 根目录下,不在 deploy/ 子目录里——这一点很容易看漏。它保护的是 OWL 服务端的鉴权入口。公开它等于把门钥匙贴在门上。
data/部署时的数据目录(记忆、会话、日志的落盘位置)。用户数据被公开,是隐私事故,不是技术失误。
node_modules/依赖目录,可能几万个小文件。仓库体积爆炸,而且每次装依赖都会产生一大堆改动。第五章会讲 npm 与 package.json 的分工。

把前两条放在一起看,你就能明白这件事有多危险:那份 .gitignore 挡住了 API Key,却没有挡住服务器私钥。而这两样东西对入侵者来说价值相当——一个花你的钱,一个拿走你的一切。当时的处境是:只要敲一次 git add .、再 git push 一次,这两份文件就会永久留在一个公开仓库的历史里。

这里还有一个更隐蔽、也更值得你记住的真实风险。qq-bot/deploy-token.txt 里的值,与 bot/config.json 里 server.token 的值完全相同——同一个凭据被复制到了两个地方。

它的危险有两层。第一层是运维层面的:同一个秘密出现两处,未来你只改了其中一处,就会出现「一半生效、一半不生效」的诡异故障——你改了 config.json 重启服务,以为鉴权已经换成新的了,但服务器上另一份仍旧是旧值,于是连接时好时坏,而这几乎不可能靠读代码看出来。第二层是安全层面的:两处就是两个暴露面,你只要漏掉一处,整个秘密就泄了。

而这恰好呼应了第 7 节要讲的那句话——.gitignore 挡住的是文件,不是这个秘密。同一串字符可以同时躺在被忽略的文件里、config.json 里(那是个会被提交的文件)、以及某次聊天记录里。所以真正安全的设计不是「想办法把这些文件藏好」,而是同一个凭据只存在一个地方,其余地方都只是引用它。第九章讲密钥管理时会把这套做法讲完。

想一想假设你现在要给服务器和机器人各配一把钥匙。方案 A:生成一份令牌,写到文件里,另一个地方复制粘贴一份。 方案 B:只在 config.json 里放一次,需要它的地方都从那里读。
请你说出方案 A 在什么情况下「看起来更省事」,以及它会在哪一天让你付出代价。你有没有在自己的项目里做过方案 A?

3.6 这次疏漏被补上了:现在的真实内容

好消息是,这次疏漏被人抓住了——抓住它的方式,恰好就是这一章教的东西:在动手之前,先想清楚「什么不该进仓库」。补全之后的 qq-bot/.gitignore 现在是这样:

# 敏感文件:不要把 API Key 提交或分享出去
bot/llm.local.json

# 凭据与私钥(这一类最要命:泄露不可撤销)
*.pem
*.key
deploy-token.txt
.env
.env.*
!.env.example

# 运行时产物
bot/bot.log
bot/*.log
data/
bot/history.json
bot/memory.json
*.selftest-backup
*.tmp

# 依赖与构建产物(体积大、可重建)
node_modules/

# 临时验证产物
_verify/

# 系统与编辑器
.DS_Store
Thumbs.db
.vscode/
.idea/
desktop.ini

▲ 这是补全后的真实内容。对比一下你就知道多出来的部分在防什么。

新补的每一条都有明确的对象:

  • *.pem 与 *.key —— 这是最关键的一条。它不针对某一个文件,而是针对一整类后缀:以后你从任何服务商下载私钥,都不会再漏。这就是为什么好的 .gitignore 要按「类别」写,而不是按「文件名」写。
  • deploy-token.txt —— 点名挡住那一个令牌文件。
  • .env 与 .env.*,后面还跟着一条 !.env.example —— 这是通配符与例外的配合写法:忽略所有环境变量文件,但保留一份可以公开的样板(里面只有键名、没有值),方便别人照着建自己的。
  • data/、bot/*.log、*.tmp —— 用户数据、日志、临时文件。
  • node_modules/ —— 依赖。结尾的斜杠说明它是个目录。
  • .DS_Store、Thumbs.db、.vscode/、.idea/、desktop.ini —— 操作系统和编辑器生成的杂项。它们和你的项目毫无关系,纯粹是噪声。

那么,下次你怎么知道自己有没有漏?不要靠眼睛看目录,用两条命令去问 Git:

# 1) 列出「所有被忽略的文件」——用来检查有没有该忽略却没被忽略的东西
git status --ignored

# 2) 问 Git:这个具体文件为什么被忽略了?是哪一行规则起的作用?
git check-ignore -v qq-bot/deploy/server-key.pem
# 输出里会告诉你是 .gitignore 的第几行、匹配了哪条模式

▲ 第一条用来「发现遗漏」,第二条用来「解释原因」。把第 2 条用在你所有觉得「这东西可能不该进仓库」的文件上——它是几秒钟的事,比事后清理历史便宜得多。

还有一条更保险的兜底习惯:第一次 git add . 之后、git commit 之前,一定跑一次 git status,把清单从头到尾读一遍。因为这时候东西只进了暂存区,还没有被封进历史——发现不对,git restore --staged 文件名 就能拿出来,一点痕迹都不留。而一旦 commit 了,代价就开始按第 7 节的方式计算了。

警告这一小节讲的是一个真实的、被抓住的疏漏,不是一个虚构的案例。它值得你记住的是两件事:第一,写下 .gitignore 的人并不比你以为的细心多少——他只是还没想到「私钥也可能躺在项目目录里」;第二,唯一可靠的防线不是细心,是流程:先分类想清楚,再写规则,然后用 git status --ignored 和 git check-ignore 去核对。
你自己做项目时也会有同样的盲区。区别只在于,你是靠一次事故才补上它,还是靠这一节提前补上它。

3.7 加上 /echo:一次真实的改动与提交

现在开始真正的任务。你想让 OWL 支持 /echo 你好,她就把「你好」复读一遍。

先看 bot.js 里指令是怎么被匹配的。真实代码是这样的:

/** 只匹配指令:命中返回 {reply},否则 null */
function matchCommand(event, text) {
  const clean = stripAt(text);
  const prefix = cfg.prefix || "/";
  const low = clean.toLowerCase();
  for (const c of cfg.commands) {
    const full = `${prefix}${c.command}`.toLowerCase();
    if (low === full || low === String(c.command).toLowerCase()) {
      return { reply: render(c.reply, varsFor(event, clean)) };
    }
  }
  return null;
}

▲ 摘自 qq-bot/bot/bot.js 的 matchCommand。它做三件事:去掉 @ 前缀、拼出「前缀 + 指令名」、逐个比对配置里的指令表。

关键在最后那行 render(c.reply, varsFor(event, clean))。它说明指令的回复内容是从配置里取的,而且会先做一次变量替换。而 varsFor 是这样的:

function varsFor(event, text) {
  const senderName = event.sender?.card || event.sender?.nickname || String(event.user_id);
  return {
    botName: cfg.botName,
    time: nowText(),
    at: `[CQ:at,qq=${event.user_id}]`,
    nick: senderName,
    userId: String(event.user_id ?? ""),
    groupId: String(event.group_id ?? ""),
    text,
  };
}

▲ 同样摘自 bot.js。注意 text 这一项:它就是用户说的原话(已经去掉了 @ 部分)。这意味着「复读用户说的话」这个功能,连代码都不用改。

于是第一次尝试非常省事:打开 bot/config.json,在 commands 数组里加一条:

{
  "command": "echo",
  "reply": "你说的是:{text}"
}

▲ 加在 commands 数组里。这个文件支持热重载,保存即生效,不用重启。

你在本地试了一下:私聊发 /echo 我今天很累,收到了「你说的是:/echo 我今天很累」。

看到问题了吗?指令名也被复读进去了。因为 varsFor 拿到的 text 是「去掉 @ 之后的整句」,而不是「去掉指令名之后的参数」。这说明你想要的是「带参数的指令」,而现在这套机制只支持「整句匹配的指令」。

这就是真实开发的常态:你以为是一个五分钟的小改动,动手才发现它需要动一个更底层的东西。而这也正是 Git 存在的理由——你现在要做一次有风险的改动,但你有一张安全网。

先提交一次。在动代码之前,把「配置里加了 echo」这件已经能跑的事封存起来:

$ git status
On branch main
Changes not staged for commit:
  (use "git restore <file>..." to discard changes in working directory)
        modified:   bot/config.json

$ git diff
diff --git a/bot/config.json b/bot/config.json
index 3f1a2b4..8c9d0e1 100644
--- a/bot/config.json
+++ b/bot/config.json
@@ -24,6 +24,10 @@
     {
       "command": "ping",
       "reply": "pong 🏓"
+    },
+    {
+      "command": "echo",
+      "reply": "你说的是:{text}"
     }
   ],

▲ git diff 显示的是工作区 vs 暂存区的差异:+ 是新增的行,- 是删掉的行。它回答的问题只有一个:我还没决定要提交的那些改动,具体是什么?

请养成一个习惯:提交前一定看一眼 git diff。它的作用不是「检查代码质量」,而是「确认这次要提交的确实是你以为的那些」。新手最常见的一种事故是:在调试时加了三行 console.log,忘了删,然后一起提交了,过了一个月才发现日志里全是调试信息。

确认无误,把它放进暂存区:

$ git add bot/config.json

$ git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   bot/config.json

▲ 注意标题从 not staged 变成了 to be committed。同一个文件,只是换了棵树。

然后封存它:

$ git commit -m "feat: 新增 /echo 指令,复读用户说的话"
[main 4b8e1c2] feat: 新增 /echo 指令,复读用户说的话
 1 file changed, 4 insertions(+)

▲ -m 后面跟的是提交信息。它开头那个 feat: 是什么,第 8 节整节都在讲。

现在做那个「有风险的改动」。你要让指令支持参数。思路是:如果用户发的整句话以「前缀 + 指令名 + 空格」开头,就把空格之后的部分当作参数,装进一个新的变量里。以下是简化版示意(真实实现还需要考虑多空格、大小写、@ 前缀等细节):

// 以下是简化版:在 matchCommand 里先试着剥掉「/echo 」这样的前缀
function matchCommand(event, text) {
  const clean = stripAt(text);
  const prefix = cfg.prefix || "/";
  const low = clean.toLowerCase();
  for (const c of cfg.commands) {
    const full = `${prefix}${c.command}`.toLowerCase();
    // 1) 整句相等:老行为,保持兼容
    if (low === full || low === String(c.command).toLowerCase()) {
      return { reply: render(c.reply, varsFor(event, clean)) };
    }
    // 2) 带参数:以「/指令名 」开头,则把后面的部分当参数
    if (low.startsWith(full + " ")) {
      const arg = clean.slice(full.length + 1);
      return { reply: render(c.reply, { ...varsFor(event, clean), arg }) };
    }
  }
  return null;
}

▲ 简化版示范,不是 bot.js 的原文。真实代码要处理「前缀是 / 但指令名大小写混用」「参数里有空格」等情况。

同时把配置里的回复改成用 {arg}:

{
  "command": "echo",
  "reply": "你说的是:{arg}"
}

试了一下,好了。私聊 /echo 我今天很累,收到「你说的是:我今天很累」。

但你在测试过程中还发现两件事:一是如果用户只发 /echo 不带参数,会显示「你说的是:undefined」,很难看;二是你顺手把 varsFor 里那一行多余的 groupId 转换删掉了,因为它对你这个改动没用。第二件事其实和「支持参数」是两件不同的事——而这就给了你一个机会,练习第 2.2 节讲的「拆成两个提交」。

先修 undefined:

// 如果没给参数,就换一句提示,而不是显示 undefined
const arg = clean.slice(full.length + 1).trim() || "(你没说要复读什么)";

然后 git status 看一眼现在有几个文件被改:

$ git status
On branch main
Changes not staged for commit:
        modified:   bot/bot.js
        modified:   bot/config.json

两个文件都改了,但你想把它们分成两个提交。做法是分别 add:

$ git add bot/bot.js
$ git commit -m "feat: 指令支持带参数,/echo 可以复读指定内容"

$ git add bot/config.json
$ git commit -m "fix: /echo 缺少参数时不再显示 undefined"

▲ 这就是暂存区的核心用途。同一个时间点改的东西,可以被拆成两个「有意义的故事」。

要点还有一个更精细的工具 git add -p(-p 是 patch,补丁)。它会把你的改动切成一块一块地问你「这一块要不要」,你可以只挑其中一部分进暂存区,剩下的留在工作区。这是把「一个改到一半的想法」和「一个已经完成的想法」分开的利器。它会问你怎么处理每一块,你按 y / n 回答即可。不用现在就用它,但要知道它存在。

3.8 git log --oneline --graph:看清你已经走到哪

现在看历史。

$ git log --oneline
8f2a91d fix: /echo 缺少参数时不再显示 undefined
c71d0b3 feat: 指令支持带参数,/echo 可以复读指定内容
4b8e1c2 feat: 新增 /echo 指令,复读用户说的话

▲ --oneline 把每个提交压成一行:短哈希 + 提交信息。默认的 git log 会显示完整哈希、作者、日期、完整信息,信息量大但不利于扫视。

再加上 --graph,它会用字符画出提交图:

$ git log --oneline --graph --all
* 8f2a91d fix: /echo 缺少参数时不再显示 undefined
* c71d0b3 feat: 指令支持带参数,/echo 可以复读指定内容
* 4b8e1c2 feat: 新增 /echo 指令,复读用户说的话

▲ 现在还是直线,因为一切都发生在 main 这一条线上。--all 表示「连其他分支一起显示」。

等你在第 5 节真的开了分支,这个命令会开始画出分叉和汇合,那时候你会看到它真正的价值。我建议你在自己的仓库上把 git log --oneline --graph --all 设成别名(第 8 节的最后一个技巧会讲怎么设),它是你之后最常用的「看地图」命令。

想一想这三个提交里,第一个提交(4b8e1c2)其实存在一个「不完整」的问题:它带进去的配置回复是 {text},而那会导致指令名被复读。也就是说,你提交了一个「能跑但明显不理想」的状态。这在 Git 里是完全正常的吗?「只提交完美的状态」和「尽早提交、把小步留档」这两条原则,你会怎么权衡?想一想你现在更倾向哪一边,以及为什么。

4. 撤销的四个层级:新手最需要、也最容易害怕的部分

这一节是整章的实用核心。你之所以怕 Git,本质上是怕一个问题的答案:「我搞砸了,还能回去吗?」

能。但「回去」有四个不同的层级,用错层级会造成不同的后果。请先看这张表,然后我们逐层展开。

  你在哪一层搞砸了?              用什么                     会不会改动历史?
  ─────────────────────────────────────────────────────────────────────────
  ① 工作区改乱了(还没 add)  →  git restore <file>         不会(只动工作区)
  ② 加错暂存区(还没 commit) →  git restore --staged <file> 不会(只动暂存区)
  ③ 刚提交就写错了(没推)    →  git commit --amend          会(改写那个提交)
  ④ 已经推送出去了            →  git revert <hash>           不会(新增一个提交)
                                 或 git reset(仅限私人分支,危险)

▲ 请把这张表抄在你的纸上。遇到「怎么退回去」时,先问自己在哪一层,再选命令——而不是反过来背命令再想它能用在哪。

4.1 第一层:工作区改乱了,还没 add

场景:你在 persona-source.txt 里试着重排【说话方式】那一段,改到一半发现自己把「不用 emoji」那条删掉了,而你不确定是不是故意的。你还没 add,所以这件事只在工作区里。

$ git status
On branch main
Changes not staged for commit:
        modified:   persona-source.txt

$ git restore persona-source.txt
$ git status
nothing to commit, working tree clean

▲ git restore <文件>:把这个文件的工作区内容,恢复成暂存区里的样子。因为你还没 add,暂存区里是上一次提交的版本,所以效果等于「丢掉我刚才在这个文件上的所有改动」。

警告这是一条会真正删掉你劳动成果的命令,而且没有回收站。它的危险程度和「不保存就关掉编辑器」一样。所以它有一个同样危险的兄弟:git restore .(后面那个点表示「所有文件」)。在你第一次用 . 之前,务必先 git status 看清楚会波及哪些文件,并且记住:一旦执行,那些改动真的没了。

顺便说一个新手常见的误判:如果你只是想「看看原来是什么样」,不要用 git restore。git diff 就是在给你看差异,git show HEAD:persona-source.txt 可以把上一次提交里的那份文件整体打印出来。先看,再决定要不要丢。

4.2 第二层:加错暂存区了,还没 commit

场景:你刚敲了 git add .——注意这个命令会把你工作区里所有改动一起加进去。结果你把两个还在半路的实验文件也加进去了。

好消息:这个层级最容易救。你只需要把它们从暂存区里取出来,工作区的改动一个字都不会丢。

$ git status
Changes to be committed:
        modified:   bot/bot.js
        modified:   experiment-a.mjs      ← 这个还不该提交
        modified:   experiment-b.mjs      ← 这个也是

$ git restore --staged experiment-a.mjs experiment-b.mjs

$ git status
Changes to be committed:
        modified:   bot/bot.js
Changes not staged for commit:
        modified:   experiment-a.mjs
        modified:   experiment-b.mjs

▲ git restore --staged 只把文件「从暂存区退回工作区」。改动还在,只是不再是「待提交」了。

你会看到网上有些教程写 git reset HEAD <file>,效果是等价的。这是历史原因:restore 和 switch 这两条更专注的命令是后来才加进 Git 的(大约 2.23 版本之后),在那之前大家只能靠一个功能繁杂的 git reset 兼做所有事——这也是 reset 让人害怕的原因之一。现在你可以安心地用 restore。

4.3 第三层:刚 commit,就发现写错了

场景:你刚才那个提交里,提交信息写错了字,或者漏加了一个文件,或者多加了一个你不该提交的文件。而它还没有被推送——也就是说,这个提交只存在于你自己的硬盘上,世界上还没有第二个人见过它。

这时候你有一个特权:你可以改写历史。

$ git add bot/config.json          # 补上漏掉的文件
$ git commit --amend -m "fix: /echo 缺少参数时不再显示 undefined,并补上配置改动"

▲ --amend(修订)会用一个新的提交替换掉上一个提交。原来那个提交会被丢进「悬空对象」,短时间内在 git reflog 里还找得到,但已经不在历史线上了。

请注意这行说明里的用词:「替换」,不是「修改」。原因在 2.4 节:提交的哈希是根据内容算出来的,改了内容哈希就必然变。所以你 --amend 之后,git log 里那个提交的短哈希会变。这在本地毫无影响(只有你一个人看得到),但如果它已经被推送,别人手里的历史就会和你不一样——这就是第 10 节「为什么 --force 危险」的根源。

技巧一个非常实用的小用法:git commit --amend --no-edit 表示「保持信息不变,只把新 add 的东西并入上一个提交」。另一个:如果你只是想把提交信息改一下,直接 git commit --amend 一个 -m 都不带,它会打开编辑器让你改信息。

还有一个必须提的救命命令:git reflog。它记录「HEAD 移动过的每一步」,包括那些已经不在任何分支上的提交。当你某次操作之后彻底找不着北了(比如误做了 reset --hard,或者从 detached HEAD 切走),git reflog 是唯一能救你的名单。它看起来像这样:

$ git reflog
8f2a91d (HEAD -> main) HEAD@{0}: commit (amend): fix: /echo 缺少参数…
c71d0b3 HEAD@{1}: commit: feat: 指令支持带参数…
9a4f2e1 HEAD@{2}: commit: fix: /echo 缺少参数…(被 amend 掉的那一版)
4b8e1c2 HEAD@{3}: commit (initial): feat: 新增 /echo 指令…

▲ reflog 是「本地操作日志」,只在你自己的机器上,不会被推送。它存在大约 90 天后自动回收。「我搞砸了但 reflog 里有」是这个世界上最让人安心的句子之一。

4.4 第四层:已经推送出去了——这里必须讲清 reset 与 revert

现在是最难的一层,因为它涉及一个判断,而不只是记忆一条命令。

你改错了人设并且推送到 GitHub 了,甚至可能已经顺手同步到了线上服务器(第七章的那条 git pull)。你想撤回。

有两条路,它们的差别是整章里最该想清楚的一处:

git resetgit revert
它做什么把分支标签挪回去,让那些提交从历史线上「消失」新增一个提交,这个提交的内容是「把之前那次改动反过来做一遍」
历史会变吗会。被跳过的提交不再属于这条线,哈希全变不会。原来的提交还在,后面多了一个
别人拉取时会怎样他们的历史和你的分叉了,会报错,必须做危险操作才能对齐他们正常 pull 就同步了,什么都没发生
能不能用在共享分支上不能(除非你确定没有别人拉过)能。这是它的设计用途
适合的场景私人实验分支、刚提交还没推、本地整理历史任何已经推送出去的、别人可能已经拉过的提交

为什么在共享分支上只能用 revert?我用 2.3 节那张图解释一遍。

你 reset 之前,所有人看到的历史:
   [a] ── [b] ── [c] ← main(你也在这里)

你执行 git reset --hard a 之后,你看到的:
   [a] ← main        ([b] 和 [c] 还在对象库里,但没人指向它们了)

但同事小明昨天已经拉过 [c]。他那边是:
   [a] ── [b] ── [c] ← main

现在两个人对「main 是什么」有了两个不同的答案。
Git 没法猜谁对,所以它只能报错,让人类去解决。
——这就是所有「莫名其妙的历史冲突」的来源。

如果改用 git revert c,情况是:
   [a] ── [b] ── [c] ── [c'] ← main
                        ↑
                  多出来的这个提交,内容等于「撤销 c」

所有人 pull 一下就对齐了。历史是「只增不改」的,所以永远不会打架。

▲ reset 改写的是「过去」,revert 追加的是「现在」。凡是别人可能已经看过的历史,一律用追加,不要用改写。

另外,reset 有三个常用档位,它们的区别只在于「顺带动哪几棵树」。既然你已经有了三棵树的模型,这条就非常好记:

  • --soft:只把分支标签挪回去。暂存区和工作区都不动。所以那几个提交的改动会「重新变成待提交状态」。适合「提交打包得太粗,我想拆开重做」。
  • --mixed(默认):挪标签 + 清空暂存区,工作区不动。所以改动会「变成未 staged 的样子」。适合「我想重新挑一遍要提交什么」。
  • --hard:挪标签 + 清空暂存区 + 把工作区也改成回退后的样子。所以你会真的丢掉那些改动。它是这一章里最危险的一条命令,很多人第一次真正丢代码都是在它手上。

警告如果你已经 reset --hard 了而且很后悔——先去跑 git reflog。只要你没有执行过 git gc 之类的清理,丢掉的提交通常还在,你能通过 git reset --hard <那个哈希> 把它接回来。请把这句话记住,它会在某个糟糕的夜里救你一次。但不要因此就放心乱用:能救,不等于不痛。

4.5 把四个层级连起来:一次完整的「我搞砸了」

最后我用一个连续的场景把这一节收起来。请你跟着往下走一遍。

  1. 你在 persona-source.txt 里改人设,改乱了 → git status 看是「not staged」→ git restore persona-source.txt,回到干净状态。
  2. 你重新改,这次改得不错,但顺手把 bot/config.json 也动了,一起 git add . 了 → git status 看到两个文件都在暂存区 → git restore --staged bot/config.json,把配置放回工作区。
  3. 你把 persona-source.txt 提交了,但提交信息打错字了 → git commit --amend,改掉信息。短哈希变了,但因为没推送,没关系。
  4. 你把它同步到线上,线上机器人开始说一些奇怪的话——因为你在人设里不小心删掉了【边界】那一段。而这份历史已经推送到 GitHub 了 → git revert <那个提交的哈希>,产生一个「撤销那次改动」的新提交,再推送。
  5. 同时你回到人设里,重新把【边界】加回去,重新提交、推送。

整个过程里,你没有在任何一步「把东西弄丢了」。这就是 Git 想给你的东西:不是让你不犯错,而是让犯错变成一件可以在十分钟内处理完的小事。

想一想第 4 步里我特意强调「已经推送了,所以用 revert」。但如果那次改动还没有被任何人拉取过,而项目只有你一个人,你会不会觉得 reset 更干净?请你说出支持 reset 的一个理由,以及一个会让你后悔用 reset 的理由。这一题没有标准答案,但有「你有没有想过」的区别。

5. 分支与合并:让两条时间线同时存在

上一节讲的是「退回去」。这一节讲的是它的另一面,也是更有价值的一面:同时进行两件事,而不让它们互相干扰。

回到序章提到的第三件事:你想比较 temperature 的 0.8 和 1.2。有了分支,这个实验的样子完全变了。

5.1 开一条实验线:git switch -c

$ git switch -c experiment/temperature-1.2
Switched to a new branch 'experiment/temperature-1.2'

▲ switch -c 的 -c 是 create,意思是「新建一个分支并立刻切过去」。新分支会从你此刻所在的提交长出来。

现在发生的事情,用 2.3 节的模型说就是:Git 在当前位置多贴了一张贴纸,然后把 HEAD 指向了这张新贴纸。你的文件一个字都没变。这就是「切分支为什么那么快」的答案。

  操作之前:
      [a] ── [b] ── [c] ← main, HEAD
                        (两个指针指着同一个提交)

  操作之后:
      [a] ── [b] ── [c] ← main
                       └─ experiment/temperature-1.2 ← HEAD

▲ 新建分支不改动任何文件。它只是多了一个名字。

现在你在 bot/config.json 里把 temperature 从 0.8 改成 1.2,跑一遍试聊,把七条固定场景的回答存进一个文件(这个动作很重要,它是你「可追溯的实验记录」),然后提交:

$ git add bot/config.json results/temp-1.2.txt
$ git commit -m "test: 把 temperature 调成 1.2,记录 7 条固定场景的回答"

然后再切回 main,开第二条实验线:

$ git switch main
$ git switch -c experiment/temperature-0.8-baseline
$ git add results/temp-0.8.txt
$ git commit -m "test: 记录 temperature=0.8 的 7 条回答作为基线"

现在你手上有一个非常干净的东西:两组数据,各自带着自己的提交,谁都不会被谁覆盖,而且都永久可查。三天后你翻历史,git show 一下就能看到当时那七条回答是什么。1.3 节里那个「我想不起第二组到底是多少度跑的」的问题,从此不存在。

要点请注意:分支不是「代码的副本」,但它带来了一种「平行宇宙」的效果。这两个词容易混:副本是你复制了一份文件,两份文件独立演化;平行宇宙是同一份文件在不同时刻呈现不同内容,而 Git 负责在你切换时把文件换成对应宇宙的样子。所以分支不会占用「两倍的磁盘」,也不会出现「两份配置不知道该信哪个」的问题——因为同一时刻只有一个宇宙是活的。

5.2 合并一:fast-forward(快进)

你的实验做完了,结论是「1.2 确实更活泼,但会在情绪场景跑偏」。你决定不采用它,于是这条实验分支的使命结束了,直接删掉就行——记得吗,删掉分支不会丢东西。

$ git branch -d experiment/temperature-1.2
Deleted branch experiment/temperature-1.2 (was 7d3e9a1).

但那七条数据你想留下。于是你用了另一个做法:把「记录数据」这件事合并回 main。

合并有两种样子,第一种最简单,叫 fast-forward(快进):

合并之前:
   [a] ── [b] ── [c] ← main
                   └─ [d] ── [e] ← experiment/xxx(HEAD)

  注意:main 在 [c],而实验线是从 [c] 长出去的。
        也就是说 main 没有「自己的新提交」,它只是停在了岔路口。

  git switch main
  git merge experiment/xxx

合并之后(fast-forward):
   [a] ── [b] ── [c] ── [d] ── [e] ← main, experiment/xxx

  main 这个贴纸直接「滑」到了 [e]。
  没有产生新的提交,历史仍然是一条直线。

▲ 快进合并的本质是:目标分支只是往前挪了一下贴纸,因为不需要发明任何新东西。

为什么能这样?因为从 [c] 到 [e] 是一条直线,main 只要顺着走下去就行。这就是理解三种合并类型的钥匙——看目标分支有没有「自己的」新提交。

5.3 合并二:三方合并(three-way merge)

现在换一个场景。你和(想象中的)另一个自己同时动了 main:main 上有一次「修文档」的提交,而实验分支上有两次「改配置」的提交。

合并之前:
                  ┌─ [d] ── [e] ← experiment/xxx
                  │
   [a] ── [b] ── [c]
                  │
                  └─ [f] ← main(HEAD)

  两个分支各自往前走了。没有一条能「快进」到另一条。

  git merge experiment/xxx

合并之后:
                  ┌─ [d] ── [e] ──┐
                  │               ▼
   [a] ── [b] ── [c]            [m] ← main(HEAD)
                  │               ▲
                  └─ [f] ────────┘

  [m] 是有两个父节点的「合并提交」。

▲ 三方合并里的「三方」指的是:两个分支的末端,加上它们的共同祖先(这里是 [c])。Git 拿这三份内容做比较,判断每一处改动该保留谁。

为什么需要共同祖先?因为只有知道「原来的样子」,Git 才能判断出「哪边改了、哪边没改」。规则大致是:

  • 只有一边改了某处 → 采用改过的那一边。
  • 两边都改了同一处、而且改法相同 → 自动采纳,不算冲突。
  • 两边都改了同一处、改法不同 → Git 停手,交给人判断。这就是冲突。

技巧有一个非常常用的参数能省掉很多麻烦:git merge --no-ff(no fast-forward,禁止快进)。它强制每次都产生一个合并提交,即使快进也能做。好处是历史里会明确留下「这里曾经有一条分支,它是为某件事开的」。第九章讲代码评审时你会看到为什么团队常常偏好这种做法——因为「这条分支代表一个完整的功能」这个信息,在几个月后非常值钱。

5.4 冲突现场:真实的样子,和真实的处理

现在讲冲突。这一小节请你不要跳,因为它是很多人第一次真正害怕 Git 的地方。我要做的是让你见过它长什么样——见过就不怕了。

假设两个分支都改了 persona-source.txt 里【其他】那一段的同一个地方。你执行 git merge,会看到:

$ git merge experiment/persona-tweak
Auto-merging persona-source.txt
CONFLICT (content): Merge conflict in persona-source.txt
Automatic merge failed; fix conflicts and then commit the result.

▲ 注意措辞:「Automatic merge failed」——自动合并失败了。它不是在骂你,它在说「这一步我做不到,需要你」。失败的是自动流程,不是你。

打开那个文件,你会看到这样的东西(下面这段请当成纯文本读):

【其他】
<<<<<<< HEAD
- 默认 70 字以内,超过 3 句就砍。
- 不用 emoji。
=======
- 默认 60 字以内,简短优先。
- 情绪场景可以稍微长一点,但不要超过 5 句。
>>>>>>> experiment/persona-tweak
- 不说教,把结论留给她自己想。

▲ 这就是冲突标记的完整样子。请把这三行记成「问题的边框」:Git 用它们把「有分歧的那一段」框起来给你看。

四个标记各自的含义:

  1. <<<<<<< HEAD —— 从这里开始,是你当前所在分支(HEAD)的版本。
  2. ======= —— 分割线。上面是「你的」,下面是「要合进来的那条分支的」。
  3. ======= 与 >>>>>>> experiment/persona-tweak 之间的部分,是要合进来的那条分支(对方分支)的版本。本例中它就是那两行关于「60 字以内」的内容。
  4. >>>>>>> experiment/persona-tweak —— 到这里结束。

而注意最后那一行 - 不说教,把结论留给她自己想。——它没有被框住,说明两边都没改它,Git 已经自动保留了。

怎么处理?答案是:像编辑普通文本一样编辑它。你要做的是手工写出一段你真正想要的内容,然后把那三行标记全部删掉。比如你决定采用对方的「60 字以内,简短优先」,同时保留自己这条「情绪场景可以稍微长一点」——因为情绪场景确实需要更多字数:

【其他】
- 默认 60 字以内,简短优先。
- 情绪场景可以稍微长一点,但不要超过 5 句。
- 不用 emoji。
- 不说教,把结论留给她自己想。

▲ 处理完的样子:标记没了,内容是你综合两边之后自己写的。这不是「选 A 还是选 B」,而是「你来拍板 C」。

然后告诉 Git「这个文件的冲突我处理完了」:

$ git add persona-source.txt
$ git status
All conflicts fixed but you are still merging.
  (use "git commit" to conclude merge)

$ git commit -m "merge: 合并人设篇幅调整,统一为 60 字优先 + 情绪场景放宽"

▲ 冲突解决后的 git add,语义不是「放进暂存区」,而是「我已经处理好了,请把它当作解决结果」。这一步是必须的,否则 Git 不会让你完成合并。

最后我要把这一节的判断写清楚,因为它比命令重要得多:

冲突不是错误。冲突是 Git 在说:这件事需要人的判断。

你想想它面对的局面:两个分支在同一个位置给了两个不同的答案。哪一个是「对」的?只有你(或你的团队)知道。Git 完全可以随便挑一个、或者两个都留、或者两个都删——但那三种做法都会在你不知情的时候破坏你的代码。它选择了最诚实的一种:停下来,把双方的意见并排摆在你面前,然后承认自己不知道。

一个从不报冲突的工具,才是真正危险的工具。

5.5 把顺序走完:一次完整的分支工作流

现在把这一节串成一条你以后每天都会走的流程。以「给 OWL 加一个 /uptime 指令,显示她已经运行了多久」为例:

  1. git switch main,git pull,确保起点是最新的(远程仓库的细节下一节讲)。
  2. git switch -c feature/uptime——开一条带名字的分支。feature/ 这个前缀不是语法要求,是一种约定(第 8 节会讲为什么这类约定值得遵守)。
  3. 改 bot/bot.js 和 bot/config.json,本地试聊。
  4. git status → git diff → git add → git commit。可能提交两三次,每次一件小事。
  5. 试聊通过,回到 main:git switch main,然后 git merge feature/uptime。
  6. 把 main 推送出去。
  7. git branch -d feature/uptime,删掉这条已经完成使命的贴纸。

第 5 步如果发生冲突,就用 5.4 节的办法处理。第 7 步删掉分支,你不会有任何损失——因为它的提交已经通过合并进入了 main。

想一想如果「个人项目也值得开分支」这句话成立,那么它的理由是什么?请注意:这里没有别人,所以「不打扰同事」这个理由不成立。请你想出至少一个只和你自己有关的理由。(提示:想一想「我改到一半,突然要紧急修一个正在线上出错的问题」这个场景。)我在第 10 节会给出我的答案,但请先自己想。

6. 远程仓库与 GitHub:为什么你的工作不该只存在一台电脑上

到这里为止,你所有的提交都只存在于你的硬盘上。这有一个显而易见的问题:硬盘会坏,电脑会丢,手会滑。而且还有 1.1 节那个问题的加强版——如果那台电脑没了,你连「回到上周」的机会都没有。

6.1 克隆:把一个仓库搬回家

远程仓库(remote repository)就是「同一个仓库的另一份,住在别的地方」。最常见的形式是托管在 GitHub 上。

最典型的第一条命令不是 init,而是 clone:

git clone git@github.com:你的用户名/owl-bot.git

▲ clone 做了三件事:把整个仓库(包括全部历史)下载下来、在当前目录建好项目文件夹、把远程地址记成一个叫 origin 的别名。

注意「包括全部历史」这点。克隆不是下载最新版代码,而是把 2.3 节那张图整张搬过来。所以克隆完之后,你能看到别人三个月前的每一次提交、每一条信息。这是 Git 与「网盘同步」最本质的区别:网盘同步的是「现在」,Git 同步的是「过去与现在」。

如果你已经有本地项目(比如我们这个 qq-bot),流程是先在 GitHub 上建一个空仓库(不要勾选自动创建 README),然后:

git remote add origin git@github.com:你的用户名/owl-bot.git
git branch -M main
git push -u origin main

▲ 第一行给远程地址起名 origin;第二行确保主分支叫 main;第三行推送,-u 表示「记住这次的关系,以后直接 git push 就行」。

关于「主分支叫 main 还是 master」:Git 早期的默认名是 master,2020 年前后业界逐渐改用 main,GitHub 也在 2020 年把新仓库的默认名改成了 main。两个名字在 Git 里没有任何功能差别,只是名字。所以你在老教程里看到 master 不要慌;如果本地是 master 而你想改成 main,用 git branch -M main 就行。

6.2 push、fetch、pull:三个看起来很像的命令

这一小节是新手最容易糊的地方,但它其实很好理清。关键还是那三棵树的思路:先分清「下载」和「合并」是两件事。

命令它做什么会不会动你的文件
git push把你本地的提交上传到远程不会(只影响远程)
git fetch把远程的新提交下载到本地,更新 origin/main 这个指针不会。你的代码一个字都不变
git pullfetch + merge:下载,然后合并进你的当前分支会。你的文件可能被改动

所以那句被反复念叨的话——pull 等于 fetch 加 merge——的价值在于:它告诉你 pull 里藏着一个可能产生冲突的步骤。

这就引出一个很实用的建议:养成先 fetch 再决定的习惯。

$ git fetch origin
$ git log --oneline --graph --all
  ← 现在你能看到「远程多了哪些提交」,但你的工作区还是干净的
$ git diff main origin/main
  ← 想知道具体差了什么,就比一下
$ git merge origin/main
  ← 确认之后,再合并

▲ fetch 让你可以在「看清楚了再决定」的前提下更新,而不是被 pull 直接推到冲突现场。

6.3 origin/main 是什么:本地分支与远程分支的跟踪关系

你在 git log 里会看到 origin/main 这样的名字。它不是远程服务器上的分支,它是你本地的一份「远程状态快照」。

   GitHub 服务器上真实的分支
        main ──► [f2a] ──► [9c1]

   你本地的两份记录
        origin/main ──► [9c1]   ← 上一次 fetch 时它长这样
        main        ──► [7b5]   ← 你自己的分支,可能落后,也可能领先

▲ origin/main 是「远程跟踪分支」(remote-tracking branch)。你不能直接在它上面提交,它只在你 fetch / pull 时被更新。

理解了这一层,几个常见的困惑就自然解开了:

  • 为什么 git status 会说「你的分支落后 origin/main 2 个提交」?因为它在对比 main 和 origin/main 这两个本地指针。这不代表远程真的还是那样——它代表的是「你上次联网时远程的样子」。
  • 为什么 git push 会被拒绝,说「远程有本地没有的提交」?因为你的 origin/main 记录了远程的位置,而远程现在比它更靠前(别人推过东西)。Git 拒绝覆盖别人,所以让你先 pull。
  • 「跟踪关系」(tracking)是什么?它只是「main 默认对应 origin/main」这条备忘录。有了它,你敲 git push 不用写 git push origin main。这条关系就是前面 -u 建立的,git branch -vv 可以查看所有分支的跟踪关系。

技巧git status 里有一句常被忽略的话很重要:「Your branch is up to date with 'origin/main'」。它意味着「你上次联网时,本地和远程一致」。它不是「远程现在和你一致」。养成在重要操作前 git fetch 的习惯,你就不会在推送时被突然拒绝。

6.4 再讲一次 SSH key:GitHub 怎么知道你是你

这是本章与第一章(计算机常识与 Linux)的呼应点,也是第七章会再次出现的东西。请认真读这一小节,因为它是本章里最容易造成真实损失的部分。

当你说 git push 时,GitHub 需要先确认「你确实有这个仓库的写入权限」。有两条路:

  1. 用令牌:你在 HTTPS 地址(https://github.com/...)上推送时输入凭据。GitHub 现在不接受账户密码,要用 Personal Access Token(个人访问令牌)。它的弱点是:这串凭据会被留在你能登录的每一台机器上、以及任何记录它的地方,被拿走一次就能被复用。(这也是为什么真实的团队会用「短期有效、可单独吊销」的令牌,而不是一个长期不变的字符串。)
  2. 用 SSH 密钥:你在本地生成一对密钥,把公钥交给 GitHub,私钥永远留在你自己机器上。

第二条路上发生的事情,用到了第一章(计算机常识与 Linux)讲过的非对称加密。但这里必须把一个细节说准,否则你会带着一个错误的印象走很久:

要点非对称加密有两种用法,不要把它们的动作混为一谈:
用法一,加密传输——用公钥加密、用私钥解密。目的是让内容在传输路上只有收件人能看懂。HTTPS 握手的第一步大致就是这个路子(第四章会讲)。
用法二,签名验证——用私钥签名、用公钥验证。这里没有加密和解密的环节,目的也不是保密,而是证明「这件事确实是持有私钥的那个人做的」。
SSH 登录走的是第二种。过程大致是:GitHub 发来一段随机的挑战数据,你的 SSH 客户端用私钥对它签名,把签名发回去;GitHub 用你之前贴上去的公钥去验证这个签名对不对。对上,就证明你手里确实有那把私钥。整个过程中你的私钥从来没有离开过你的电脑,也从来没有被「解密」过。

理解了这个区别,你才能明白为什么「把公钥交出去」是安全的:公钥只能用来验证,不能用来签名。所以就算 GitHub 的数据库整个被人搬走,别人拿到的也只是一堆公钥——那东西本来就可以公开,拿它没办法冒充你。而私钥一旦泄露,别人就能以你的身份签名,GitHub 会完全认不出来。这就是「不用把密码交给对方,也能证明你是你」的完整机制。

$ ssh-keygen -t ed25519 -C "你的邮箱@example.com"
  ← 会在 ~/.ssh/ 下生成两个文件
     id_ed25519        私钥:绝对不要给任何人、不要上传、不要提交
     id_ed25519.pub    公钥:这个才是要贴到 GitHub 上的

▲ ed25519 是当前推荐的密钥算法类型;-C 后面的注释只是给你的密钥起个可读标签。生成时如果问你「passphrase」,可以设一个口令(更安全),也可以留空(更方便)。

验证是否配对成功:

$ ssh -T git@github.com
Hi 你的用户名! You've successfully authenticated...

▲ 看到这句话就说明配对成功。第一次连接会问你是否信任 github.com 的指纹,输入 yes。

警告你正在工作的这个项目里,就有两份这样的东西真实地躺着:qq-bot/deploy/server-key.pem(登录云服务器的私钥,第七章还要用它)和 qq-bot/deploy-token.txt(一份令牌,注意它在 qq-bot/ 根目录下,不在 deploy/ 子目录里)。
我们在 3.5 节讲过:本书写作时,旧版的 .gitignore 里并没有忽略 *.pem 和 deploy-token.txt,一个 git add . 就会把它们写进历史,而只要推送一次,就永远留在仓库里了。这一条疏漏已经被补上:现在仓库里的 .gitignore 明确包含 *.pem、*.key、deploy-token.txt、.env 与 data/。
但请你把这件事的教训放在规则之上:真正救你的不是那份文件,是「先想一想什么不该进仓库」这个动作。下一节讲的就是这个动作的全部内容。

想一想假设你有一台笔记本和一台云服务器,两台机器都要能推送到同一个 GitHub 仓库。你会:把同一对密钥复制到两台机器上,还是各自生成一对、把两个公钥都交给 GitHub?请说出你选那种做法的理由,以及它的代价。(提示:想一想如果哪天笔记本丢了,你需要做什么,以及哪种做法让这件事更容易。)

7. .gitignore 与密钥安全:这一节请你读两遍

我要在这一节开门见山地说一句话,因为它的后果是这一章里最严重的:

警告Git 是一台设计上就不打算删除任何东西的时间机器。这是它的优点,也是它最危险的地方。你误提交的一个密钥,会把「一次手滑」变成「一件永久的事」——而且推送之后,你无法确定它已经被多少台机器、多少个爬虫、多少个自动扫描服务复制过。

7.1 一个 Node 项目必须忽略什么

3.4 到 3.6 节已经带你走完了这个项目自己的「原状 → 疏漏 → 补全」过程。这一小节把它上升成一张换任何项目都能套用的分类清单——你以后每建一个新仓库,都该在第一次 git add 之前把它过一遍:

# ---- 依赖与构建产物(体积大、可重建、机器生成)----
node_modules/
dist/
*.selftest-backup

# ---- 密钥与凭据(最要命的一类)----
llm.local.json
.env
.env.*
!.env.example
*.pem
*.key
deploy-token.txt

# ---- 用户数据与运行时状态(隐私 + 每次都变)----
data/
bot/logs/
bot/history.json
bot/memory.json

# ---- 日志与临时文件 ----
*.log
logs/
*.tmp

# ---- 验证脚本的临时产物 ----
_verify/

# ---- 系统与编辑器 ----
.DS_Store
Thumbs.db
.vscode/
.idea/

▲ 一份通用模板。和 3.6 节那份真实文件对照着看,你会发现结构完全一样:先分类,再写规则。

逐条说清它们各自在防什么:

条目为什么必须忽略
node_modules/它可能有几万个小文件、几十到几百 MB。它是完全可以从 package.json + 锁定版本文件重建出来的。把它提交进去会让你的仓库变得巨大,而且每次装依赖都改一堆文件。第五章会讲 npm 与 package.json 的分工,那时你会更明白为什么「源码进仓库、依赖不进仓库」是标准做法。
llm.local.json里面是 API Key。它一直都在这份 .gitignore 里,是项目最早做对的一处设计——理由下一小节讲。
*.pem 与 *.key这些后缀通常就是私钥。qq-bot/deploy/server-key.pem 是你登录云服务器的钥匙;拿到它的人可以登上你的服务器,那就等于拿到了 OWL 的全部、以及服务器上的一切。注意规则写的是「后缀」而不是「文件名」——这样以后你从任何服务商新下载的私钥都自动被挡住。
.env环境变量文件,习惯上用来放密钥、数据库密码、服务地址。它是行业里最常见的密钥泄露源头之一。
data/ 与 bot/logs/用户数据。OWL 面对的是高中生,她的 memory.json 里可能写着「这个人的父母在吵架」这类话。把它提交公开,是一次真实的隐私事故,不是一次技术失误。
*.log日志会不断变。它进了仓库,你的每一次 git status 都会被噪声淹没,你就不再想看 status 了——而那是一件更糟的事。

要点.gitignore 支持路径模式和通配符:node_modules/ 结尾的斜杠表示「这是一个目录」;*.log 表示「任何以 .log 结尾的文件」;!keep.log 用感叹号开头表示「这个例外不要忽略」;data/ 会忽略任何层级的 data 目录,而 /data/(前面加斜杠)只忽略仓库根目录下的那一个。这些差别在你踩过一次之后就会记住,现在知道有这回事就行。

7.2 一个正面的例子:Key 为什么单独放一个文件

这个项目在这一件事上做得很对,值得你抄下来当模板。

它的 API Key 不是写在 config.json 里的,而是写在另一个文件 bot/llm.local.json 里,而那个文件被 .gitignore 排除了。AI-SETUP.md 里把三条理由写得很清楚:

⚠️ 为什么要单独一个文件:它已被 .gitignore 忽略,不会被误提交;你分享 config.json 给别人时不会连 Key 一起泄露;这个文件里的 Key 优先级高于 config.json。

而 bot/llm.mjs 里的读取顺序,正是这段设计的实现:

/**
 * API Key 读取优先级:
 *   1. 环境变量 DSH_QQBOT_LLM_KEY
 *   2. bot\llm.local.json  ({ "apiKey": "..." })   <- 推荐,不会被写进聊天记录
 *   3. config.json 里的 llm.apiKey(不推荐)
 */
#loadKey() {
  if (process.env.DSH_QQBOT_LLM_KEY) return process.env.DSH_QQBOT_LLM_KEY.trim();
  const p = path.join(this.botDir, KEY_FILE);
  try {
    if (fs.existsSync(p)) {
      const j = readJson(p);
      if (j.apiKey) return String(j.apiKey).trim();
    }
  } catch (e) {
    this.log(`⚠️  ${KEY_FILE} 解析失败: ${e.message}`);
  }
  const fromConfig = this.getConfig()?.llm?.apiKey;
  return fromConfig ? String(fromConfig).trim() : "";
}

▲ 摘自 qq-bot/bot/llm.mjs。它在尝试三个来源,谁先有就用谁。顺序本身就是一种安全策略。

请你仔细看这个优先级顺序里的智慧。它的排序不是随便定的:

  • 环境变量排第一。因为环境变量不存在于任何文件里,所以它天然不可能被 git add 抓到。在服务器上(第七章)这是最干净的放法:部署脚本里注入,磁盘上不留痕。
  • 独立的本地文件排第二。因为它可以被 .gitignore 精确地排除掉,同时它又比环境变量好改(改完不用重新启动进程)。
  • 写进 config.json 排最后,且标注「不推荐」。因为 config.json 是你最可能分享给别人的文件、最可能被上传的文件,也是最容易被截图发到群里的文件。把最不该泄露的东西,放在最容易被分享的地方,就是这里要防的事。

技巧这个模式有个通用名字:把配置和密钥分开。配置(模型名、温度、指令表)可以进仓库,因为它对所有人都有用;密钥绝不进仓库,因为它只对你有用。你以后每接一个新服务(数据库、地图 API、对象存储),都照这个模式做一次:密钥单独一个被忽略的文件。这条习惯的价值,会在你第一次真正泄露密钥之后才彻底显现,但那时候就太晚了。

警告但请记住一件很容易误解的事:.gitignore 挡住的是「文件」,不是「这个秘密」。同一串字符可能同时躺在被忽略的 llm.local.json 里、一个会被提交的 config.json 里、以及你某次贴给 AI 的对话里。.gitignore 只解决了第一种。这就是 3.5 节那个真实例子的含义——deploy-token.txt 里的值与 config.json 里 server.token 的值完全相同,所以哪怕你把其中一个文件挡得再好,秘密仍然在另一个地方。
而且这一点直接决定了第 7.3 节的处理顺序:因为挡住文件不等于保住秘密,所以出事后的第一步永远是「让这个秘密失效」,而不是「把文件藏起来」。

7.3 如果密钥已经被提交、并且推送了

现在讲最坏的情况。请你不要跳过——因为「知道最坏情况怎么办」,是你敢用 Git 的底气之一。

假设你手滑,把 bot/llm.local.json 里的 Key 连同一次提交推到了 GitHub 的公开仓库。十分钟后你发现了。

请按这个顺序做。顺序不是随意的,每一步都对应一个你可能想不到的风险。

  1. 第一步:立刻去服务商那里把旧 Key 作废,并生成新 Key。登进 DeepSeek(或你用的那家)的控制台,撤销/删除那把 Key,新建一把。这是唯一真正有效的动作。
  2. 第二步:检查账单和用量。在控制台上看最近几小时/几天的调用记录与消费。如果有人在刷你的 Key,你会立刻看到异常的调用量。同时确认你有没有设置消费上限——如果没设,现在设一个。因为一个泄露的 Key 被人拿去跑大量请求,账单可以是任意数字。
  3. 第三步:把新 Key 写进本地的 llm.local.json,并确认它已被 .gitignore 覆盖。然后再去清理 Git 历史。
  4. 第四步(可选、且只在你确认有必要时做):清理历史里的那次提交。可以用 git filter-repo 之类的工具重写历史,或者(在只是最近一次提交时)用 git rebase -i 把它去掉,然后强制推送。

警告为什么第三步不能提前到第一步?因为「清理 Git 历史」这个动作完全不能挽回已经泄露的密钥。请想清楚它为什么不能:从你推送那一刻起,这份内容就已经离开了你的控制范围。可能有别人的克隆、可能有 GitHub 的缓存、可能有第三方在爬取公开仓库(这是真实存在且大规模发生的事)、可能有 fork。删掉历史里的那一次提交,只是让未来的读者看不到它;它无法让过去的读者忘记它。

所以正确的表述是:

清理历史的作用是「减小暴露面」,不是「撤销泄露」。

撤销泄露的唯一方式,是让那把密钥失效。失效之后,就算全世界的爬虫手里都攥着它,它也是一串没有用的字符。

这也是一个通用原则:凡是被泄露过的东西,一律当作「已经公开」处理,然后换掉它。密码、令牌、密钥都一样。不要赌「应该没人看到」。

另外还有一件容易忘的事:如果你用的是「同一个 Key 在多个地方用」(比如机器人上用一个,你本地测试也用同一个),那么换 Key 的时候要把所有用到它的地方一起换掉。这个项目的设计正好帮了忙——就 API Key 而言,它只有 llm.local.json 一个来源(或者服务器上的环境变量),所以换起来只是改一个地方。而如果你当初把 Key 硬编码进了 bot.js 里,你现在就得满仓库找它。「好设计让危机处理变简单」,这就是一个真实的例子。(提醒一句:这个项目里另外那个 token 就没这么幸运——它同时躺在 deploy-token.txt 与 config.json 两处,见 3.5 节。这正是「一个秘密只应该存在一个地方」值得当成硬规矩的原因。)

想一想你把这个 Key 发到过哪些地方?请诚实列一列:可能包括某个 QQ 群里问问题时贴过、某个 AI 对话里贴过、某个截图里出现过、某个 .txt 里备份过。然后想一想:如果明天你发现 Key 泄露了,你能在十分钟内列全所有需要换的地方吗?如果不能,你现在的做法就有问题——问题不在 Git,在你把密钥放在了太多地方。

8. 提交信息:你写给未来自己的信

这一节看起来最「软」,但它的回报周期最长。因为几个月后,你唯一能依靠的就是这些文字。

8.1 为什么 fix 和 更新 是无用的

先看一段真实的历史(这是我编的,但它是你三个月后必然的样子):

a4f1c2e 更新
9b3d071 fix
2cd8a4f 更新一下
77e2b19 改了点东西
19fa35c 修复bug
c0d4e82 更新

现在请你回答一个问题:如果 OWL 从上周开始偶尔不回消息,你怀疑是这六个提交里的某一个造成的,你能从这六行里得到任何线索吗?

不能。这六行提供的信息量是零。你唯一能做的事是把六个提交挨个打开看差异——而这正是「提交信息」本该替你省掉的劳动。

更糟的是另一件事:这六行还污染了你搜索历史的能力。当你想找回「我上次是怎么修那个 BOM 问题的」,你会用 git log --grep=关键词 去搜。但如果所有提交都叫「更新」,你搜什么都是零结果。

要点提交信息不是「给别人看的礼貌」,而是一个检索用的索引。你写它的那一刻,是在为未来的自己建一个搜索入口。所以判据很简单:这句话能不能让我在三个月后仍然知道那次改了什么、为什么改?能,就是好信息;不能,就是噪声。

8.2 类型: 做了什么 的惯例

业界逐渐形成了一套约定,叫 Conventional Commits(约定式提交)。格式是一行前缀 + 冒号 + 描述:

<类型>: <做了什么>

▲ 就这么简单。类型用小写英文单词,冒号后面是中文描述也完全没问题(这个仓库的提交信息就是中文的)。

常用的几个类型:

类型含义在本项目里长什么样
feat新功能(feature)feat: 新增 /echo 指令,复读用户说的话
fix修 bugfix: /echo 缺少参数时不再显示 undefined
docs只改文档docs: 在 HOW-TO-TUNE.md 里补上验收清单的说明
refactor重构:行为不变,内部结构变好refactor: 把指令匹配的循环拆成小函数
test增删改测试test: 给 /echo 补 3 个离线用例
chore杂务:依赖、配置、脚本chore: 升级 ws 到 8.x

为什么这套前缀值得遵守?因为它让历史可以被机器读。你在第 9 节会看到,GitHub Actions 可以自动做检查;而很多项目会用提交类型自动生成更新日志(「本次版本新增了哪些 feat、修了哪些 fix」)。如果你一开始就写对了,你以后想要什么统计都可以自动得到;如果你一开始写「更新」,以后只能靠人补。

还有一条对个人项目最实用的能力:

git log --grep="fix:" --oneline     # 只看所有修 bug 的提交
git log --since="2026-03-01" --oneline
git log -- bot/llm.mjs --oneline    # 只看动过这个文件的历史
git log -p -- bot/config.json       # 连差异一起看

▲ 这四条命令是「用信息换时间」的典型。它们只有在你的提交信息有内容的前提下才有意义。

8.3 为什么「为什么这么改」比「改了什么」更重要

这是本节最重要的一句话,我要把它讲透。

git diff 已经能告诉你「改了什么」了。它是机器算出来的,精确、完整、不遗漏。你写在提交信息里的「改了什么」,是对同一件事的一次更差的复述。

而「为什么」是任何工具都算不出来的东西。只有当时的你知道。而它恰恰是未来最需要的信息——因为「改了什么」是事实,不会引起争论;「为什么」是判断,而判断会在几个月后变得无法理解。

看这个例子。这个项目真实踩过一次 BOM 的坑,我把两种写法并排放给你:

写法提交信息三个月后的你能得到什么
坏fix: 修一下 config 读取什么也得不到。你甚至会以为问题在「读取逻辑」上。
好fix: 读 JSON 时剥掉 UTF-8 BOM,避免 PowerShell 写配置后 JSON.parse 直接崩你会立刻想起:哦,是那次我用 Set-Content 改了配置,机器人在读取阶段就崩了,所以 token 和人设全没生效,非常隐蔽。

第二种写法只多了二十个字,但它把三样关键信息永久留了下来:症状(JSON.parse 崩、读配置失败)、原因(PowerShell 的 Set-Content -Encoding UTF8 会写 BOM,而 JSON.parse 不容忍 BOM)、场景(改配置之后才出现)。

而这个项目里真的有这样一份验收脚本,就是专门为这个坑写的:

/**
 * 配置健壮性验收:确认 bot 能容忍带 UTF-8 BOM 的 config.json。
 *
 * 背景:用 PowerShell 的 `Set-Content -Encoding UTF8` 改配置会写入 BOM,
 * 而 JSON.parse 不容忍 BOM。实测踩过一次——改了配置后机器人直接崩,
 * 而且因为崩在读取阶段,token 和人设全都没生效,非常隐蔽。
 */
// 关键:给副本配置**加上 BOM**(模拟被 PowerShell 改过的样子)
const withBom = "\uFEFF" + JSON.stringify(cfg, null, 2);
fs.writeFileSync(sandbox.configPath, withBom, "utf8");

▲ 摘自 qq-bot/tools/verify-bom.mjs。注意它的注释里写的正是「为什么」——好的验收脚本和好的提交信息,做的是同一件事:把当时的判断固化下来。

顺便,同一件事在 HOW-TO-TUNE.md 里的表述是这样的:

别写入 BOM。用 vim 没问题;如果用 PowerShell 的 Set-Content -Encoding UTF8 改,会写 BOM(代码已做兼容,会剥掉,但保持干净更好)。

一份代码、一份文档、一条提交信息,三处都在说同一件事。这就是工程里「一个教训要落在三个地方」的样子:代码修好它,文档提醒人,提交信息留下原因。第九章讲复盘时我们还会回到这个模式。

8.4 五组好/坏对照,全部取自你这个项目的真实场景

坏的写法好的写法好在哪
更新 feat: 新增 /echo 指令,把用户说的话复读回去方便自测链路 给出了类型、功能、以及它存在的理由(自测链路)。半年后你想删掉它时,会先看一眼这个理由。
改了下人设 docs: 人设【说话方式】改为 60 字优先,因为实测 70 字回复太长 把「改前是什么、为什么改」写进去。以后你想回到 70 字时,知道当初为什么离开它。
fix bug fix: 读 JSON 时剥掉 UTF-8 BOM,避免 PowerShell 写配置后 JSON.parse 直接崩 症状 + 原因 + 触发条件,三样齐全。这是本章最该抄走的一条。
加了点限制 feat(limits): 单人每分钟 6 次、全局每分钟 60 次,防止有人刷爆 API 额度 把数字和它的目的一起写下来。以后想放宽限额时,你至少知道当初在防什么。括号里的 limits 是可选的范围标记,表示这次改动集中在哪一块。
修复温度问题 test: 记录 temperature=1.2 的 7 条固定场景回答,结论是情绪场景会跑偏 把实验结论写进信息里。这样即使你不合并这个分支,结论也没有白做——它会永远留在历史里可被搜索。

技巧写不出好信息的常见原因是一次提交里塞了太多事。如果你发现自己在写「并且」,那说明你该回去用暂存区把它拆成两个提交(第 3.5 节)。好的提交信息往往是「好的提交粒度」的结果,而不是靠文字功夫堆出来的。

最后送你一个立刻能用的配置:给你的终端装上这几个常用别名,从此 git st、git lg 就是你的日常。

git config --global alias.st "status -sb"
git config --global alias.lg "log --oneline --graph --all --decorate"
git config --global alias.last "log -1 --stat"

▲ alias 是「用一个短名字代替一串参数」。-sb 是「简短状态」,--decorate 会把分支名字显示在图上。这三行是我建议你在这一章里唯一必须照抄的配置。

9. GitHub 上除了存代码还有什么

很多人对 GitHub 的理解停在「免费的代码网盘」。如果你的仓库只是一个网盘,那你只用到了它百分之二十的价值。

我把剩下的部分按「你现在能用上的顺序」排给你。

9.1 README:仓库的门面,也是你的思考记录

README 是别人(以及三个月后的你)打开仓库时看到的第一份文件。它通常包含:这个项目是什么、怎么跑起来、需要什么依赖、当前有哪些已知问题。

你手上就有一份很好的范例:qq-bot/README.md 里有一张「当前状态」表,写着机器人 QQ 号、NapCat 版本、通信方式、已验证了什么;有一节「目录结构」,把每个文件的作用标出来;还有一节「常见问题」,把 spawn EPERM、wrapper.node 缺失、二维码过期、机器人不回消息这四类真实踩过的坑和排查顺序写了下来。

这份 README 的价值不在于它给谁看,而在于:它是这个项目唯一一份「整体视角」的文档。代码告诉你每一步怎么做,README 告诉你这些东西是怎么拼在一起的。第九章会把 README 当作一个正式的工程能力来讲——包括「怎么给一个开源项目写一份别人愿意读的 README」。

要点一个很实用的习惯:每次你在项目里踩到一个坑并解决之后,就去 README 的「常见问题」里加一条。你写的不是文档,你写的是「下一次的自己不再浪费那两个小时」。这个项目现在就享受到了这份红利——那四条常见问题,每一条都对应一次真实的挫败。

9.2 Issue:把「待办」和「问题」从脑子里搬出来

Issue(议题)是仓库里的一个讨论条目。它可以是一个 bug 报告,可以是一个新功能的想法,可以是一个疑问。

对个人项目来说,Issue 最被低估的用途是:把它当作一个「外置的大脑」。你在深夜发现「群里有人连着问了五次『怎么进群』,我应该让 OWL 认出这个问题」——但你现在不想改。那就开一个 Issue 写下来:现象是什么、你打算怎么处理、暂时不做的原因。

这样做有三个好处:第一,你不会忘;第二,如果你两个月后决定做它,你有一份当时的一手记录;第三,如果你把项目开源,别人看到这个 Issue 就知道「这件事已经被想到了,只是还没做」——这比让别人重复提一遍有价值得多。

9.3 Pull Request:同时是讨论区、评审区和自动检查入口

Pull Request(简称 PR,中文常译「拉取请求」)是 GitHub 上的核心协作机制。它的形式是:你说「我在 feature/uptime 这条分支上做了一些改动,请把它合并到 main」。然后 GitHub 会给你一个页面,上面有:

  • 这次改动的完整差异(相当于一整份 git diff),可以逐行评论。
  • 一个讨论区,你和别人可以在具体某一行下面说话。
  • 一个检查区:如果仓库配置了自动化检查,每一次推送都会触发它们,结果直接显示在这个页面上(绿勾 / 红叉)。

所以 PR 不是「一个请求合并的按钮」,它是这三样东西的合体。这也是为什么第九章讲代码评审时会说:PR 的真正价值不在合并,而在「合并之前,改动被看见了一次」。

对个人项目,PR 有一个也许更重要的用法:自己给自己开 PR。你先在分支上把功能做完,开一个 PR,然后在 PR 页面里把完整差异从头读一遍。你会发现一件很神奇的事——在 PR 页面上读自己的代码,和在对编辑器里读,是两种不同的阅读。因为前者的心态是「我要为这段代码辩护」,后者的心态是「我要让它跑起来」。这两种心态会看到不同的东西。

9.4 Actions:把 verify-*.mjs 变成「提交即验收」

现在讲这一节最有意思的部分,它直接连到第九章。

你已经见过这个项目的验收工具了。它们都在 qq-bot/tools/ 里,而且分成两类:

工具检验什么花钱吗
verify-persona.mjs人设结构、危机识别、记忆、安全开关(离线结构检查)免费
verify-flow.mjs完整消息链路:指令优先级、@触发、上下文、/重置、/忘记、超长拦截、限流(用假 LLM)免费
verify-token.mjs鉴权相关(动过 token 逻辑之后跑)免费
verify-bom.mjs配置容错:带 BOM 的 config.json 能不能正常启动免费
verify-values.mjs / verify-cute.mjs / verify-style.mjs / verify-stability.mjs价值观不说教、可爱度、提问频率、安全底线的稳定性几分钱(真调 API)

▲ 这张表摘自 qq-bot/HOW-TO-TUNE.md 第六节「改完的验收清单」。它的每一行都在回答「我改完这次,怎么知道没弄坏别的」。

请注意这张表的形态:「什么情况下跑什么命令」被写成了一张表。这说明一件事——验收不是一个「临场想起来就做」的动作,而是一个被设计出来的流程。

GitHub Actions 做的事,是把这张表搬到服务器上自动执行。它的机制大致是:你在仓库里放一个配置文件(在 .github/workflows/ 目录下,用 YAML 格式),里面写「在什么情况下(比如有人向 main 推送、或者有人开 PR)执行什么命令」。之后每次推送,GitHub 就会开一台临时机器,把代码拉下来,按你写的命令跑一遍,然后把结果贴在那次推送或那个 PR 上。

对我们这个项目来说,它意味着:你每次推代码,verify-flow.mjs 和 verify-bom.mjs 会自动跑一遍。如果它们失败了,你会立刻知道「这次改动弄坏了指令链路」或者「配置容错被破坏了」——而不是等到三天后群里有人问「机器人怎么不回话了」。

把这一小节和本章的主题连起来看,你会看到一个完整的闭环:

Git 让每一次改动都留下一个可回退的状态点。
验收脚本让每一次改动都留下一个「有没有弄坏东西」的结论。
把两者接起来,你得到的是:任何一次「机器人变坏了」,你都能定位到是哪一次改动造成的,也能看到那一次改动的验收结果是什么。

第九章会把这条闭环讲完——包括测试怎么写、验收怎么设计、以及「自动化验收」为什么是工程能力的分水岭。

你现在不需要会写 Actions 的配置。你需要的是知道它存在,并且知道它为什么存在:因为人是会忘记跑验收的,而机器不会。

9.5 读别人的 PR 与提交

这一小节我想给你一个可能有点意外的建议:去读别人的 Pull Request,这是提高最快的方式之一。

为什么?因为你读一份 PR,读到的不是一个「最终答案」,而是一个判断过程:有人遇到了什么问题、他试了哪几种做法、别人在评论里提出了什么反对意见、最后为什么选了这一条路。这些内容在教程里几乎看不到——教程只给你结论,PR 给你的是得出结论的过程。

具体怎么做:找一个你正在用的工具(比如你依赖的那个 ws 库,或者 NapCat),进它的仓库,切到「Closed」状态的 PR 列表,随便点开一个。你不用看懂全部代码,你只要看三件事:

  1. 这个 PR 想解决什么问题?(读它的标题和描述)
  2. 它在哪些地方动了代码?(读改动范围,看它影响了几个文件)
  3. 评论里在争论什么?(这是最有价值的部分)

坚持读十份,你会发现一件很值钱的事:你会开始知道「一个改动到什么程度算做好了」。这个判断力,任何教程都给不了你。

想一想如果你把自己这三个月为 OWL 做的所有改动,都做成一串规范的提交和 PR,那么「你学会做机器人」这件事,本身就成了一个可以被别人读懂的故事。
反过来说:如果你做成了,但历史里全是 更新、fix,那这个故事还剩多少?你现在做的每一次提交,是在为哪种未来投票?

10. 协作礼仪与三种常见事故

这一节的名字叫「协作」,但你可能会想:「我只有一个人,哪来的协作?」

请先接受一个判断:你至少有一个协作者,就是三个月后的你。而下面这三种事故,就算只有你一个人,也一样会发生。

10.1 事故一:push --force 抹掉别人的提交

git push --force 的意思是:「不管远程现在是什么,把它强行改成我这里的样子。」

用 2.3 节的图来看,它做的事是:把远程的分支标签直接挪到你本地的位置,中间那些远程独有的提交就此从分支上消失。

远程:  [a] ── [b] ── [c] ── [d]   ← 小明昨晚推的 [d]
本地:  [a] ── [b] ── [c] ── [x]   ← 你今早基于 [c] 提交的 [x]

git push --force 之后:
远程:  [a] ── [b] ── [c] ── [x]

[d] 去哪了?它还在服务器的对象库里(短期内),
但没有任何分支指向它 —— 对使用者来说,它消失了。
小明第二天 pull,会发现自己昨天的工作「没了」。

▲ 这就是「force push 抹掉别人的提交」。它不是把对方的工作删掉,而是让整个团队的分支不再指向它——效果上一样糟糕。

那什么时候可以 --force?答案是:当你确定这条分支上除了你以外,没有任何人拉取过它。典型场景是「我自己的功能分支,还没开 PR,我想把历史整理干净」。

而更安全的做法是 --force-with-lease:

git push --force-with-lease

▲ 它的意思是「强制推送,但前提是远程从我上次看到它之后没有被别人改过」。如果有人在你之后推了新东西,它会直接拒绝,而不是覆盖它。

请把这条记住:如果你确实需要强制推送,永远用 --force-with-lease,不要用 --force。这不是「更礼貌」的问题,这是一个真正的安全检查——它把「我以为没人动过」这个假设交给了 Git 去验证,而不是让你靠记忆去赌。

警告这里的底层原因,还是 2.5 节讲的「HEAD 与哈希」。--force-with-lease 之所以能判断出「有人动过」,是因为它比较的是远程分支的哈希值是否还是你记忆里的那个。所以「哈希锁定整条链」这件事,不只是理论上的优雅,它是真实保护你的机制。

10.2 事故二:直接往 main 上改

这个事故没有报错、没有警告,所以它比第一种更常见。

它的样子是:你想到一个改动,直接 git switch main,改,提交,推送。看起来一切正常。

它的问题在三个地方,而且都不立刻显现:

  1. 你失去了「这是一件事」这个单元。main 上的历史变成一串互相无关的小提交。当你以后想「把上周那个功能整体撤掉」时,你会发现它们散落在各个角落,无法一次撤回。
  2. 你失去了「改到一半可以停下」的能力。你已经改了三个文件,测试还没跑,但你已经提交了两个。这时如果线上出问题要紧急修,你的 main 处于「半成品」状态——而你没有一个干净的基线可以基于它修。
  3. 你失去了「先看再合」的那个动作。5.4 节讲的那个「PR 页面上读自己代码」的机会,再也不会出现了。

而「个人项目也值得开分支」的理由,最实在的一条是这个:

分支给了你一个「可以随时丢弃」的空间。

你在 feature/xxx 上试了一个想法,试了两小时,发现此路不通。你只需要 git switch main,然后 git branch -D feature/xxx——一切就像没发生过。而如果你是在 main 上试的,你要么把那些提交也留在历史里(永远带着),要么用 reset 去处理(有风险,而且要小心)。

「可以丢弃」这件事本身有价值,因为它让你敢试。这又回到了第 1 节的结论。

顺带说一个实用约定:很多团队会保护 main 分支——禁止直接推送,只能通过 PR 合并。GitHub 上可以配置这项规则(叫 branch protection)。对个人项目,我建议你也把它当规矩:不直接推 main。不是为了防止别人破坏,是为了让「开分支 → 做完 → 开 PR → 自己读一遍 → 合并」这个动作变成习惯。这个习惯在你有同事的第一天,会立刻变成优势。

10.3 事故三:把密钥提交上去

第三种事故在第 7 节已经讲透了,这里只补一个礼仪层面的点。

如果你在别人的仓库(或者一个公开仓库)里发现了一个泄露的密钥,正确的做法是:

  • 不要把它复制出来、不要截图、不要发到任何群里去「验证一下」。你拿走它,就已经构成了对别人资源的访问。
  • 先私下通知维护者(GitHub 上很多仓库都有 SECURITY.md,写明怎么报告安全问题;或者直接开一个不包含密钥内容的 Issue)。
  • 如果涉及真实用户数据,这个优先级高于「我是不是多管闲事」。说出来是对的做法。

10.4 一份可以直接拿来用的日常守则

把这三节压缩成七条,你从今天就可以照做:

  1. 动手前先 git status,确认起点是干净的。
  2. 开工前 git pull(或者先 fetch 看看)。
  3. 一件新事开一条新分支,名字带上前缀(feature/、fix/、docs/)。
  4. 提交前 git diff 看一遍,确认没有调试残留、没有密钥。
  5. 提交信息写「类型 + 做了什么 + 为什么」。
  6. 推送前 git log --oneline --graph --all 看一眼历史形状。
  7. 需要强制推送时,用 --force-with-lease,永远不用 --force。

这七条你不需要背。你只需要在最初两周里,每次操作前瞄一眼这张清单。两周之后,它们会变成肌肉记忆。

想一想第 4 条「提交前看一遍 git diff」看起来只是一次检查。但它其实在训练一种更贵的能力:你开始把自己的改动当成「别人的改动」来看。请你想想:这个能力,和你以后读别人的 PR(第 9.5 节)需要的能力,是不是同一个?

11. 一个真实教训:改完怎么验证,和 Git 有什么关系

现在我要讲这一章最后、也是我个人认为最重要的一个故事。它是真实的,写在这个项目的官方文档里。

11.1 那个教训本身

先看原文(引自 qq-bot/HOW-TO-TUNE.md 第七节「一个真实教训」):

只靠提示词压不住「习惯性行为」。

你说「不要总是刨根问底」,我第一版只在人设里加了「别总问问题」—— 结果实测提问反而变多了(3/8 → 6/8 轮)。

原因是:模型对「别养成某个习惯」这类结构性要求,提示词杠杆很弱。最后是靠代码硬约束(llm.mjs 的 limitToSingleQuestion + 把最近几轮的提问次数统计出来告诉它)才压到 1/8。

请你注意这个故事里的三个数字:3/8、6/8、1/8。

3/8 是改之前的状态:八轮对话里有三轮 OWL 问了你问题。你说「太爱问了」,于是去改人设。

6/8 是改之后的状态:变多了。你为了减少提问而做的改动,让提问翻了一倍。

1/8 是最后的状态:换了做法(代码硬约束),才真正压下去。

现在我要问你一个问题,这个问题是这一节的钥匙:

想一想如果没有版本控制,你怎么知道「改之前是 3/8」?
更进一步:如果那个 3/8 是你两周前随手测的、当时只记在了聊天记录里,而你现在测出来是 6/8——你能确定是「你的改动导致的」吗?还是可能「那两周模型更新了」「测试的场景不一样」「你这周心情不好问的问题不同」?
请先在心里回答,再往下读。

11.2 没有版本控制,你无法回答「是谁弄坏的」

让我们把这个问题放大到一般情形。

你有一个能跑的系统,你对它做了一件事,然后它变坏了。你想知道:「是哪件事让它变坏的?」

如果没有版本控制,你只有两个办法:

  1. 靠记忆。「我上周好像改过配置……还是这周?那天我还改了人设。」——记忆是有偏的:它会自动把你最近的、印象最深的改动放到最前面,而忽略那些「看起来无关」的小改动。而 bug 恰恰常常藏在「看起来无关」的地方。
  2. 猜。你猜是 A,于是你把 A 改回去,试试看;不行,再猜 B。这个过程有两个问题:一是慢,二是你在猜的过程中又改动了系统,于是你离「原始状态」越来越远,最后连「原来的问题还在不在」都不确定了。

而有了版本控制,这个问题的形状完全变了:

它从「一道靠记忆的推理题」,变成了一道「可以执行的机械流程」。

你不再需要「想清楚是哪一次」,你只需要去试。因为每一个状态点都被保存着,你可以把任何一个旧状态取出来,跑一遍,看问题在不在。这是一件「有一台机器替你记住所有过去」之后才可能做的事。

11.3 git bisect:把「找哪一次」变成一次二分查找

有一条命令,就是专门为这个问题设计的。它叫 git bisect(bisect 是「二分」的意思)。

它的思路非常漂亮,而且你其实已经学过一个同名的算法——二分查找。你对一本按拼音排好序的字典查一个字,不会从第一页开始翻,你会翻到中间,判断「我要找的字在左边还是右边」,然后丢掉一半。查一千页的字典,最多翻十次。

git bisect 把你的提交历史当成一本「有序字典」:早期的提交是「好的」,最近的提交是「坏的」,中间一定有一个提交是那道分界线。于是它问你:

$ git bisect start
$ git bisect bad                 # 当前这个提交是坏的
$ git bisect good 4b8e1c2        # 这个老的提交是好的(问题不存在)

Bisecting: 7 revisions left to test after this (roughly 3 steps)
[3a9f2c1] feat: 指令支持带参数,/echo 可以复读指定内容
  ← Git 自动切到了中间那个提交,等你测试

$ ... 你在这个状态下跑一次,判断它是否正常 ...
$ git bisect good        # 或 git bisect bad

Bisecting: 3 revisions left to test after this (roughly 2 steps)
  ← 又切到区间中间,继续

... 重复几次 ...

8c9d0e1 is the first bad commit
  ← 它自己找出了「第一个变坏的提交」

$ git bisect reset       # 结束,回到原来的位置

▲ 注意那些 roughly 3 steps 的提示:100 个提交,大约只需要 7 次测试。这就是二分的力量。

请注意 git bisect 里那个关键的动作:git 帮你切换工作区到那个历史提交。这正是 2.3 节那张图的直接应用——因为每个提交都是一个完整快照,所以「跳到三个月前的某一天」是一次瞬间的、安全的操作。如果没有「快照」这个设计,二分查找根本不可能实现,因为你没法「跳到中间那个状态」。

你现在不需要会用 git bisect。你需要的是知道它存在,并且理解它为什么可能存在。当你下次听到有人说「版本控制让调试变简单」,他说的就是这件事。

要点git bisect 还有一个自动化模式:git bisect run <命令>。你给它一条命令,它自己在每一步跑这条命令,根据退出码(0 表示通过,非 0 表示失败)自动判断「好」还是「坏」,然后自己往下找,最后直接告诉你答案。
现在你能看出这个项目里那些 verify-*.mjs 脚本的另一个用途了吗?它们都以「失败时返回非零退出码」的方式结束(比如 verify-bom.mjs 最后那句 process.exit(failed.length ? 1 : 0))——这个小小的约定,让它们可以被任何自动化工具当作「通过/不通过」的判据。这是第九章「自动化验收」的技术底座。

11.4 这一节真正想说的是什么

回到那个 3/8 → 6/8 → 1/8。

那个教训里最值钱的部分,不是「提示词压不住结构性行为」这个结论,而是得出这个结论的过程:先测出一个基线(3/8),改一次,再测(6/8),发现变差了,于是换方向,再测(1/8)。

而这个过程成立的前提是:你能确定「3/8」和「6/8」之间,除了那一次改动之外,没有别的东西变了。

这就是 Git 在这一章之外给你的东西。它让你能做真正的对照实验:

  • 基线固定在某个提交上(那个提交里,人设是原版、代码是原版)。
  • 实验组是另一个提交,它只比基线多了你想要验证的那一次改动。
  • 两次测试的所有条件都一样,区别只有那一处。

这叫控制变量。它是序章里「心法三:一切都要亲手验证」的技术前提——没有版本控制,「亲手验证」这句话就只是热情;有了版本控制,它才成为一种方法。

所以这一章最后我要把话说得更直白一点:

Git 让你能回到过去,这是它的功能。
但它的意义在于:它让你能对时间做实验。

你可以问「如果我那次没有加这句话,会怎样」——然后真的去看。这类问题,在没有版本控制的世界上只有一个答案:「不知道」。而在有版本控制的世界上,它是一个五分钟的测试。

第七章部署时,你会再遇到这个问题一次,而且是最凶的一次:线上代码更新了、机器人挂了,你要判断是「新代码的问题」还是「环境的问题」。到那时你会庆幸自己在这一章学会了 git log 和 git diff——因为那是你唯一能分清这两件事的工具。

自查:你是不是真的懂了

先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。

  1. 用你自己的话说:Git 的「快照模型」和「差异模型」的区别是什么?为什么这个区别能解释「我可以直接跳到三个月前的那一版」?
    参考答案

    差异模型保存的是「相对上一版改了哪几行」,想要得到任意时刻的样子,必须从某个起点把所有改动重放一遍;快照模型保存的是「每一次提交时整个项目的状态」,所以任意一个历史状态都可以被直接取出来,不需要重放。区别的关键不是「存得少还是存得多」,而是「能不能直接跳到任意时刻」。能直接跳,才谈得上「并排比较两版」——而这正是第 1 节三件事的共同解药。常见错误答案:说「快照存全部文件所以更占空间、但更安全」。Git 实际上会对内容做去重和压缩(内容相同的文件在多个快照里只存一份),所以「占空间」并不是这个设计的主要代价。如果你用「空间」来解释它,说明你还在用「文件备份」的模型想它。

  2. 请把「工作区 / 暂存区 / 版本库」这三棵树,和下面四个 git status 状态一一对上:Untracked、Changes not staged for commit、Changes to be committed、working tree clean。
    参考答案

    Untracked:文件在工作区,但 Git 不认识它,暂存区和版本库里都没有它。
    Changes not staged for commit:文件在版本库里有旧版本,工作区里是新版本,暂存区里还是旧版本(所以「未暂存」)。
    Changes to be committed:暂存区里已经是新版本,但还没被 commit 封进版本库。
    working tree clean:三棵树内容一致(对 Git 跟踪的文件而言)。
    判断诀窍:git status 的每个小标题,说的都是「哪棵树和哪棵树不一样」,而不是「这个文件好不好」。

  3. 你说「我在 main 分支上」。如果「分支只是一个贴在提交上的可移动标签」,那这句话严格来说是在说什么?请用「指针 + 位置」的语言重说一遍,并解释「为什么切换分支那么快」。
    参考答案

    它说的是:HEAD 这个指针当前指向 main 这个标签,而 main 这个标签贴在提交图中的某一个具体提交上(比如 c71d0b3)。也就是说,「我在 main 上」= 「我此刻所在的位置是 c71d0b3,并且我的下一次提交会让 main 这个标签从 c71d0b3 挪到新提交上」。
    切换分支快,是因为切换的动作是「把工作区的文件改成目标提交的内容」+「把 HEAD 指过去」,而不是「复制一份代码」。常见错误答案:说「快速是因为文件小」。项目大时切换也会慢(因为要改写很多文件),但它慢的原因是「改写文件」,不是「拷贝仓库」。

  4. 情境题:你在 persona-source.txt 里改了半小时人设,改完发现整段都跑偏了,你想全部丢掉、回到改动之前。但你不记得自己有没有 git add 过。你应该先做什么?
    参考答案

    先 git status。这不是多余的一步,它是唯一能告诉你答案的一步。因为你必须知道改动在哪棵树里,才能选对命令:
    如果显示在 Changes not staged for commit 里 → 用 git restore persona-source.txt。
    如果显示在 Changes to be committed 里(你已经 add 过)→ 先 git restore --staged persona-source.txt,再 git restore persona-source.txt。
    如果文件是 Untracked(说明这个文件从来没被提交过,Git 不认识它)→ git restore 对它无效,因为它没有「上一版」可以回。这种情况只能手工删改。
    为什么这个答案是对的:选命令的依据永远是「改动在哪一层」,而不是「我记得我做过什么」。这也解释了第 4 节那张表为什么要按层级而不是按命令来组织。

  5. 危险操作辨析:git reset --hard HEAD~1 和 git revert HEAD,如果这条提交已经推送到 GitHub 而别人可能已经拉过,你会选哪个?为什么另一个会出事?
    参考答案

    选 git revert HEAD。reset 会把分支标签挪回去,让那个提交从历史线上消失——于是别人手里(已经拉过那个提交)的历史和你手里的历史分叉了,他们下次 pull 时会遇到无法自动解决的分歧,必须做危险操作才能对齐。而 revert 是新增一个提交,内容是把那次改动反过来做一遍。历史是「只增不改」的,所有人的历史都还能对齐,一次普通 pull 就同步了。
    补充:如果这条分支只有你自己、而且没有任何人拉过,reset 是干净的做法。所以判断的关键不是「哪条命令更先进」,而是「这段历史有没有被分享出去」。
    --hard 还额外做一件事:它会把你工作区里未提交的改动一起丢掉,这部分内容不在任何提交里,reflog 也救不了。

  6. 情境题:你 git push 被拒绝了,提示大意是「远程有本地没有的提交,请先整合」。这句话在说哪两个东西不一致?你应该怎么处理,以及为什么不应该直接 push --force?
    参考答案

    它在说「远程分支现在的位置」和你本地的记录 origin/main 不一致——远程比你上次联网时更靠前,说明有人(或另一台机器上的你)推了新提交。正确做法是先 git fetch 看清楚多了什么(git log --oneline --graph --all、git diff main origin/main),再决定 git merge origin/main 或 git rebase origin/main,解决可能的冲突之后再推送。
    不能直接 --force,是因为那会把别人那些提交从分支上抹掉(第 10.1 节)。
    如果确实需要强制推送(比如整理自己独有的功能分支历史),用 --force-with-lease:它在「远程被你上次看到之后又被别人改过」时会拒绝,而不是覆盖。

  7. 「把密钥提交上去之后,第一件事是删掉那次提交、清理历史」——这句话错在哪里?请写出正确的三步顺序。
    参考答案

    错在把「清理历史」当成了「撤销泄露」。从推送那一刻起,内容就已经离开了你的控制范围:可能有别人的克隆、fork、第三方爬虫的副本、平台缓存。删掉历史里的那次提交,只是让未来的读者看不到它,无法让已经拿到它的人忘掉它。
    正确顺序:(1)立刻去服务商作废旧 Key 并生成新的(这是唯一真正有效的动作,它让泄露出去的那串字符变成一个没用的字符串);(2)检查账单与用量、并设置消费上限(看有没有人已经在刷你的额度);(3)确认新 Key 放在被 .gitignore 覆盖的位置,然后才考虑清理历史(减小暴露面)。
    为什么顺序不能换:因为第 1 步是唯一能止损的动作,而第 3 步(清理历史)耗时最长、最容易做错,如果先做它,泄露的 Key 在这段时间里一直是可用的。

  8. 易混概念辨析:git fetch 和 git pull 的差别是什么?为什么「pull 就是 fetch 加 merge」这句话值得记住?请举一个「先 fetch 更好」的场景。
    参考答案

    fetch 只做「下载」:把远程的新提交取到本地,更新 origin/main 这个远程跟踪分支,完全不动你的工作区和你的分支。pull 是 fetch 之后再自动做一次 merge,所以它会改动你的文件,也可能把你直接推进冲突现场。
    记住那句话的价值在于:它提醒你 pull 里藏着一个可能产生冲突的步骤,所以 pull 不是一个「无害的同步按钮」。
    先 fetch 更好的场景:你手上的改动还没提交完,不确定远程上有什么。这时 git fetch 然后 git log --oneline --graph --all 可以让你先看清楚别人改了什么,再决定是先提交自己的、还是先合并、还是先切分支。

  9. 动手题:从零建一个仓库,制造一次冲突,然后解决它。请写出你会敲的命令序列(不必写具体输出,但要写出每一步在做什么,以及冲突时你如何判断该保留哪一边)。
    参考答案

    一个可行的序列:
    1. mkdir conflict-lab && cd conflict-lab;git init;git config user.name / user.email(如果这台机器没配过)。
    2. 新建一个文件(比如 persona.txt)写三行内容,git add .,git commit -m "docs: 初始版本"。
    3. git switch -c tweak-a,把第二行改成 A,add + commit。
    4. git switch main,把同一行改成 B,add + commit。
    5. git merge tweak-a → 出现 CONFLICT。
    6. 打开文件,看到 <<<<<<< HEAD / ======= / >>>>>>> 三行标记:上半是 HEAD(main 的 B),下半是 tweak-a 的 A。
    7. 自己写一段最终内容(可以是 A、可以是 B、也可以是新写的一句),把三行标记全部删掉。
    8. git add persona.txt → git commit(Git 会给你一个默认的合并信息,可以改)。
    9. git log --oneline --graph --all 看那张网:应该能看到一个有两个父节点的合并提交。
    判断该保留哪一边的依据:不是「哪个分支更对」,而是「这两份内容各自的意图是什么、哪一份符合你现在想要的结果」——如果两边都对,就写出一个两者兼顾的第三版。这就是「冲突需要人的判断」的实际含义。

  10. 动手题:请为你自己的 qq-bot 项目写一份完整的 .gitignore(不要修改别的文件)。列出至少八条,并说明每一条分别在防哪一类问题。
    参考答案

    一份合格答案应覆盖四类(这正是 3.4–3.6 节和 7.1 节的结构):
    ① 依赖与构建产物:node_modules/、dist/(体积大、可重建、每次装依赖都变)。
    ② 密钥与凭据:llm.local.json、.env、*.pem、*.key、deploy-token.txt——这一类最重要,因为泄露不可撤销。注意 deploy-token.txt 在 qq-bot/ 根目录下,不在 deploy/ 子目录里,写规则时别写错路径。
    ③ 用户数据与运行时状态:data/、bot/history.json、bot/memory.json、bot/*.log——涉及隐私,而且每次运行都在变。
    ④ 日志、临时产物与编辑器杂项:*.log、*.tmp、_verify/、*.selftest-backup、.DS_Store、.vscode/——噪声。
    判据:一条 .gitignore 规则是否该存在,问三个问题——它会变吗?它含密钥或用户数据吗?它能被重新生成吗?三个里有一个是「是」,它大概就不该进仓库。
    写完之后必须自查(这一步很多人省掉,也就是省掉了唯一的保障):git status --ignored 看有没有该被忽略却露在外面的文件;再对每个可疑文件跑一次 git check-ignore -v 文件名,确认是哪一行规则在挡它。尤其注意:这份清单必须写在第一次 git add . 之前——事后再补,只对未来的文件有效,已经提交进去的东西不会自己消失。

  11. 开放题:如果 Git 只能给你一个功能,你会选「能回到过去」还是「能同时进行两件事(分支)」?请给出你的理由,并说明你放弃的那个功能会带来什么具体损失。
    参考答案

    没有唯一正确的答案,但有「有没有想清楚」的区别。几个可以参考的角度:
    选「回到过去」的理由:它是前提。没有可回退的历史,我根本不敢同时进行两件事——因为任何一条实验线搞砸了都回不去。恐惧会先杀死我尝试的意愿。
    选「分支」的理由:回到过去只在我犯错后有用,而分支每天都在用——它改变的是我做事的形状(一件一件事、可丢弃、可比较),而不只是兜底。
    也可以指出两者其实不可分:分支之所以轻,正是因为每个提交是完整快照(2.1 与 2.3 节是同一个设计的两面)。如果你能说出「它们其实是一个设计的两面」,说明你真的读懂了第 2 节。

自问自答:把知识变成你自己的

这些问题没有标准答案,有些甚至没有答案。请不要在页面上浏览,拿一张纸写下来。写的过程就是思考的过程。

  • 过去一个月里,有哪一次「改坏了想退回去」的经历?当时如果你有 Git,你会退回到哪个时刻?那个时刻现在还记得吗?
  • 我有没有一个习惯,是把重要的东西只放在一个地方(一个硬盘、一个 QQ 收藏、一个聊天记录里)?我打算什么时候改掉它?
  • 我上一次写「更新」作为提交信息是什么时候?如果三个月后我回头看那条,我希望它写的是什么?
  • 「随时可以退回去」这件事,会让一个人变得更大胆,还是更随便?请各举一个你自己的例子。
  • 我手上有没有已经泄露过的密钥(发给过群、贴给过 AI、截进过图)?我打算什么时候把它们全部换掉?
  • 如果我把自己做 OWL 的这三个月做成一份公开的提交历史,我希望别人从里面读出什么?我现在的提交信息支持这个愿望吗?
  • 第 11 节那个 3/8 → 6/8 → 1/8,如果换成我来记录,我会怎么设计这个实验?我需要固定哪些条件?
  • 「让别人来审查我的改动」这件事,对只有一个作者的项目有意义吗?如果有,谁来当那个审查者?
  • 分叉(fork)、克隆(clone)、分支(branch)这三个词,我现在能一句话说清各自的区别吗?说不清的那一个,是哪个?
  • 如果明天我的电脑彻底坏了,我的 OWL 会怎样?请具体列出我会失去什么、不会失去什么,以及哪些损失是我现在就能提前拆掉的。

小结

这一章说了三件事。

一、Git 的心智模型。每个提交是一次完整快照,不是一条差异;你手下有三棵树(工作区改、暂存区挑、版本库封);提交连成一张有向图,箭头从新指向旧;分支只是一个可移动的标签,所以它轻、快、删了也不丢东西;HEAD 指向你此刻在哪。这五句话是这一章的地基,其他所有命令都可以从它们推出来。

二、撤销的四个层级与分支的两条时间线。改乱工作区用 restore,加错暂存区用 restore --staged,刚提交没推用 --amend,已经推出去用 revert。判断的依据永远不是「哪条命令更强」,而是改动在哪一层、以及这段历史有没有被分享出去。而在共享分支上只做追加、不改写历史,是整个协作模型的底线。

三、安全与责任。.gitignore 要挡住四类东西:密钥、用户数据、机器生成的垃圾、每次都变的东西。密钥泄露时的第一步不是删历史,而是让它失效——因为清理历史减小的是暴露面,不是撤销泄露。这一条以后会在任何项目里跟着你。

最后我想说一件关于这一章本身的事。

这一章的命令数量,是前面几章里最多的。但我不希望你记住的是命令。我希望你记住的是第 1 节那三件事,和你当时读它们时的感觉:想不起改之前是什么样、不敢试新功能、做完实验也说不清结论是怎么来的。

Git 是一种把「时间」变成一种可以取用的东西的技术。它给你的不是安全感,而是一种可以自由浪费的奢侈——你可以把 temperature 改成 1.5 试试看,可以整段重排人设,可以用三个不同的写法做同一件事,然后比较,然后丢掉那两条不好的。

一个人敢试多少次,大致等于他能学到多少东西。这一章就是把「敢试」变成一个工程上的默认设置。

第七章我在讲部署时会说:线上代码更新之前,先 git log 看一眼这次要上线的是什么。那时你会用到这一章的每一个概念。到那个夜里,你敲下 git pull,看着屏幕上一行行文件更新的日志滚过去——你会知道,那台远在上海的机器,正在把它自己变成你硬盘上那一次提交的样子。

延伸:可以去哪里继续

网站

  • Pro Git 中文版——这可能是所有技术书里最值得读的一本官方文档。第一章到第三章讲的正是本章内容(而它讲得更细、更严谨)。什么时候看:现在就可以开始读第二章「Git 基础」和第三章「Git 分支」。它是免费的,也可以在站内切换成中文。
  • Learn Git Branching——一个可以玩的 Git 分支练习场,有中文界面。它最厉害的地方是把你敲的每一条命令画成一张提交图,让你亲眼看到标签是怎么挪动的。什么时候看:读完本章第 5 节之后立刻去玩半小时,把「分支是标签」这件事变成肌肉记忆。
  • Oh Shit, Git!?!(中文版)——「我又把 Git 搞砸了」的急救手册。它按症状组织:我提交错分支了、我想撤销一次提交、我把 rebase 搞乱了。什么时候看:不要在学的时候看,等你真的搞砸了、慌的时候去看。它的价值在于让你知道这些事故都有名字、都有解法。
  • GitHub Docs(中文)——GitHub 官方文档,中文翻译质量不错。当你不确定「这个按钮到底做了什么」时,来这里查,而不是去搜博客。
  • Conventional Commits(约定式提交)——本章第 8 节讲的 类型: 描述 格式的规范原文。它很短,十分钟能读完。什么时候看:在你决定给自己的项目写正式提交信息之前。

值得读的书(两本就够,第三本按需)

  • 《Pro Git》(Scott Chacon、Ben Straub)——上面那个网站的纸质版。它从「Git 是什么」讲到「Git 内部是怎么实现的」,第 10 章「Git 内部原理」会把你这一章学到的所有心智模型从「比喻」升级成「事实」。什么时候读:现在读前四章;等你有半年经验、开始好奇「Git 到底怎么存的」,再回去读第 10 章。基础:只要会命令行。
  • 《Git 权威指南》(蒋鑫)——中文世界里讲 Git 最细的一本书,对 reset、rebase、引用与对象这一套讲得比官方文档还清楚。什么时候读:当你已经能熟练提交与合并、但对「reset 的三个档位到底动了什么」还觉得心里没底的时候。它不是入门书,不要一开始就读它。
  • 《软件工程:实践者的研究方法》(Roger Pressman)——不推荐现在读。我把它列在这里只是想让这一栏保持诚实:本章讲的版本控制与协作只是工程实践的一小块,如果你以后想知道「为什么团队要这么做」,你需要一本讲软工全貌的书。但它对现在的你太重了,等第九章之后再说。

提醒不要收藏了就算看过。这一章只要求你做一件事:把 qq-bot 变成一个真正的仓库,写好 .gitignore,提交一次,推到 GitHub 上。这一件事做完,你以后所有的试错都有了退路。如果你还想多做一件,那就开一条 experiment/ 分支,把 temperature 改成 1.2,跑一遍那七条固定场景,把结果提交在分支上——然后合不合都行,因为那个结论已经被留下来了。