第三章 · 给时间拍照
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,把它当作备份、协作与自动检查的入口
需要的前置:第一章(计算机常识与 Linux)里的「文件、路径、命令行」——你要会在终端里 cd 到某个目录、会看当前在哪。第二章(JavaScript 编程基础)里的「你在编辑一个文件」这个动作——Git 管的正是这些文件。你不需要懂 Node.js,也不需要懂网络。
这一章会为后面铺路:第四章(HTTP、JSON、API)里你会在两个程序之间来回改,改错是常态;第五章(Node.js)里你会开始装依赖,而依赖目录绝不能进仓库;第七章(部署与运维)里你更新线上代码的那条命令就是 git pull——如果这一章的远程仓库没学明白,第七章你会卡在第一步;第九章(工程素养)讲的自动化验收,入口就是这一章最后提到的 GitHub Actions。
回头看看:序章里我说过,把 Git 从你原本的路线里提前,是因为「从你写第一行代码开始,你就会不停地改坏东西」。现在你已经在第二章写过一些代码了,请回想一下:你有没有过一次「改坏了,但回不去」的经历?如果有,这一章就是在替你做那件事。
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 图。真正的区别不是「存什么」,而是「你能不能直接跳到任意一个时刻」。
为什么这件事这么重要?因为它决定了三件你以后每天都会用到的事:
- 「回到某个时刻」是一次直接跳转,不是一次逆运算。你不需要知道中间发生过什么,就能直接看到三月十二号下午那版代码。这是「能对比」的前提。
- 历史是可以分叉的。因为每个快照都是完整的,你完全可以造出两条不同的时间线,让它们各自往前走,最后再决定要不要把某一段拿回来。
- 删除一个提交不会连带毁掉别的东西。(这一点我们到分支那节会讲透。)
要点「快照」这个词还有一层含义: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 那种短编号。
它有三个你必须知道的特性:
- 它是根据内容算出来的。算它的输入包括:整棵项目的文件树、这次提交的信息、作者、时间,以及父提交的哈希值。内容变一个字符,算出来的结果就完全不同。
- 所以它是「不可伪造的身份」。两个内容相同的提交会有相同的哈希;内容不同则一定不同。这也是为什么 Git 敢说「历史可以被验证」——如果你偷偷改了某个旧提交的内容,它以及它后面所有的哈希都会变,一眼就能看出。
- 于是哈希也成了「整条链的指纹」。最新那个提交的哈希,隐含地锁定了它之前的所有历史。这是 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 reset | git 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 把四个层级连起来:一次完整的「我搞砸了」
最后我用一个连续的场景把这一节收起来。请你跟着往下走一遍。
- 你在
persona-source.txt里改人设,改乱了 →git status看是「not staged」→git restore persona-source.txt,回到干净状态。 - 你重新改,这次改得不错,但顺手把
bot/config.json也动了,一起git add .了 →git status看到两个文件都在暂存区 →git restore --staged bot/config.json,把配置放回工作区。 - 你把
persona-source.txt提交了,但提交信息打错字了 →git commit --amend,改掉信息。短哈希变了,但因为没推送,没关系。 - 你把它同步到线上,线上机器人开始说一些奇怪的话——因为你在人设里不小心删掉了【边界】那一段。而这份历史已经推送到 GitHub 了 →
git revert <那个提交的哈希>,产生一个「撤销那次改动」的新提交,再推送。 - 同时你回到人设里,重新把【边界】加回去,重新提交、推送。
整个过程里,你没有在任何一步「把东西弄丢了」。这就是 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 用它们把「有分歧的那一段」框起来给你看。
四个标记各自的含义:
<<<<<<< HEAD—— 从这里开始,是你当前所在分支(HEAD)的版本。=======—— 分割线。上面是「你的」,下面是「要合进来的那条分支的」。=======与>>>>>>> experiment/persona-tweak之间的部分,是要合进来的那条分支(对方分支)的版本。本例中它就是那两行关于「60 字以内」的内容。>>>>>>> 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 指令,显示她已经运行了多久」为例:
git switch main,git pull,确保起点是最新的(远程仓库的细节下一节讲)。git switch -c feature/uptime——开一条带名字的分支。feature/这个前缀不是语法要求,是一种约定(第 8 节会讲为什么这类约定值得遵守)。- 改
bot/bot.js和bot/config.json,本地试聊。 git status→git diff→git add→git commit。可能提交两三次,每次一件小事。- 试聊通过,回到 main:
git switch main,然后git merge feature/uptime。 - 把 main 推送出去。
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 pull | fetch + 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 需要先确认「你确实有这个仓库的写入权限」。有两条路:
- 用令牌:你在 HTTPS 地址(
https://github.com/...)上推送时输入凭据。GitHub 现在不接受账户密码,要用 Personal Access Token(个人访问令牌)。它的弱点是:这串凭据会被留在你能登录的每一台机器上、以及任何记录它的地方,被拿走一次就能被复用。(这也是为什么真实的团队会用「短期有效、可单独吊销」的令牌,而不是一个长期不变的字符串。) - 用 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 的公开仓库。十分钟后你发现了。
请按这个顺序做。顺序不是随意的,每一步都对应一个你可能想不到的风险。
- 第一步:立刻去服务商那里把旧 Key 作废,并生成新 Key。登进 DeepSeek(或你用的那家)的控制台,撤销/删除那把 Key,新建一把。这是唯一真正有效的动作。
- 第二步:检查账单和用量。在控制台上看最近几小时/几天的调用记录与消费。如果有人在刷你的 Key,你会立刻看到异常的调用量。同时确认你有没有设置消费上限——如果没设,现在设一个。因为一个泄露的 Key 被人拿去跑大量请求,账单可以是任意数字。
- 第三步:把新 Key 写进本地的
llm.local.json,并确认它已被.gitignore覆盖。然后再去清理 Git 历史。 - 第四步(可选、且只在你确认有必要时做):清理历史里的那次提交。可以用
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 | 修 bug | fix: /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 列表,随便点开一个。你不用看懂全部代码,你只要看三件事:
- 这个 PR 想解决什么问题?(读它的标题和描述)
- 它在哪些地方动了代码?(读改动范围,看它影响了几个文件)
- 评论里在争论什么?(这是最有价值的部分)
坚持读十份,你会发现一件很值钱的事:你会开始知道「一个改动到什么程度算做好了」。这个判断力,任何教程都给不了你。
想一想如果你把自己这三个月为 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,改,提交,推送。看起来一切正常。
它的问题在三个地方,而且都不立刻显现:
- 你失去了「这是一件事」这个单元。
main上的历史变成一串互相无关的小提交。当你以后想「把上周那个功能整体撤掉」时,你会发现它们散落在各个角落,无法一次撤回。 - 你失去了「改到一半可以停下」的能力。你已经改了三个文件,测试还没跑,但你已经提交了两个。这时如果线上出问题要紧急修,你的 main 处于「半成品」状态——而你没有一个干净的基线可以基于它修。
- 你失去了「先看再合」的那个动作。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 一份可以直接拿来用的日常守则
把这三节压缩成七条,你从今天就可以照做:
- 动手前先
git status,确认起点是干净的。 - 开工前
git pull(或者先fetch看看)。 - 一件新事开一条新分支,名字带上前缀(
feature/、fix/、docs/)。 - 提交前
git diff看一遍,确认没有调试残留、没有密钥。 - 提交信息写「类型 + 做了什么 + 为什么」。
- 推送前
git log --oneline --graph --all看一眼历史形状。 - 需要强制推送时,用
--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 没有版本控制,你无法回答「是谁弄坏的」
让我们把这个问题放大到一般情形。
你有一个能跑的系统,你对它做了一件事,然后它变坏了。你想知道:「是哪件事让它变坏的?」
如果没有版本控制,你只有两个办法:
- 靠记忆。「我上周好像改过配置……还是这周?那天我还改了人设。」——记忆是有偏的:它会自动把你最近的、印象最深的改动放到最前面,而忽略那些「看起来无关」的小改动。而 bug 恰恰常常藏在「看起来无关」的地方。
- 猜。你猜是 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——因为那是你唯一能分清这两件事的工具。
自查:你是不是真的懂了
先自己回答,再展开参考答案。凡是你只能靠「感觉」回答的,说明还要再读一遍。
-
用你自己的话说:Git 的「快照模型」和「差异模型」的区别是什么?为什么这个区别能解释「我可以直接跳到三个月前的那一版」?
参考答案
差异模型保存的是「相对上一版改了哪几行」,想要得到任意时刻的样子,必须从某个起点把所有改动重放一遍;快照模型保存的是「每一次提交时整个项目的状态」,所以任意一个历史状态都可以被直接取出来,不需要重放。区别的关键不是「存得少还是存得多」,而是「能不能直接跳到任意时刻」。能直接跳,才谈得上「并排比较两版」——而这正是第 1 节三件事的共同解药。常见错误答案:说「快照存全部文件所以更占空间、但更安全」。Git 实际上会对内容做去重和压缩(内容相同的文件在多个快照里只存一份),所以「占空间」并不是这个设计的主要代价。如果你用「空间」来解释它,说明你还在用「文件备份」的模型想它。
-
请把「工作区 / 暂存区 / 版本库」这三棵树,和下面四个
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的每个小标题,说的都是「哪棵树和哪棵树不一样」,而不是「这个文件好不好」。 -
你说「我在 main 分支上」。如果「分支只是一个贴在提交上的可移动标签」,那这句话严格来说是在说什么?请用「指针 + 位置」的语言重说一遍,并解释「为什么切换分支那么快」。
参考答案
它说的是:HEAD 这个指针当前指向
main这个标签,而main这个标签贴在提交图中的某一个具体提交上(比如c71d0b3)。也就是说,「我在 main 上」= 「我此刻所在的位置是c71d0b3,并且我的下一次提交会让 main 这个标签从c71d0b3挪到新提交上」。
切换分支快,是因为切换的动作是「把工作区的文件改成目标提交的内容」+「把 HEAD 指过去」,而不是「复制一份代码」。常见错误答案:说「快速是因为文件小」。项目大时切换也会慢(因为要改写很多文件),但它慢的原因是「改写文件」,不是「拷贝仓库」。 -
情境题:你在
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 节那张表为什么要按层级而不是按命令来组织。 -
危险操作辨析:
git reset --hard HEAD~1和git revert HEAD,如果这条提交已经推送到 GitHub 而别人可能已经拉过,你会选哪个?为什么另一个会出事?参考答案
选
git revert HEAD。reset会把分支标签挪回去,让那个提交从历史线上消失——于是别人手里(已经拉过那个提交)的历史和你手里的历史分叉了,他们下次pull时会遇到无法自动解决的分歧,必须做危险操作才能对齐。而revert是新增一个提交,内容是把那次改动反过来做一遍。历史是「只增不改」的,所有人的历史都还能对齐,一次普通pull就同步了。
补充:如果这条分支只有你自己、而且没有任何人拉过,reset是干净的做法。所以判断的关键不是「哪条命令更先进」,而是「这段历史有没有被分享出去」。--hard还额外做一件事:它会把你工作区里未提交的改动一起丢掉,这部分内容不在任何提交里,reflog也救不了。 -
情境题:你
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:它在「远程被你上次看到之后又被别人改过」时会拒绝,而不是覆盖。 -
「把密钥提交上去之后,第一件事是删掉那次提交、清理历史」——这句话错在哪里?请写出正确的三步顺序。
参考答案
错在把「清理历史」当成了「撤销泄露」。从推送那一刻起,内容就已经离开了你的控制范围:可能有别人的克隆、fork、第三方爬虫的副本、平台缓存。删掉历史里的那次提交,只是让未来的读者看不到它,无法让已经拿到它的人忘掉它。
正确顺序:(1)立刻去服务商作废旧 Key 并生成新的(这是唯一真正有效的动作,它让泄露出去的那串字符变成一个没用的字符串);(2)检查账单与用量、并设置消费上限(看有没有人已经在刷你的额度);(3)确认新 Key 放在被.gitignore覆盖的位置,然后才考虑清理历史(减小暴露面)。
为什么顺序不能换:因为第 1 步是唯一能止损的动作,而第 3 步(清理历史)耗时最长、最容易做错,如果先做它,泄露的 Key 在这段时间里一直是可用的。 -
易混概念辨析:
git fetch和git pull的差别是什么?为什么「pull就是fetch加merge」这句话值得记住?请举一个「先 fetch 更好」的场景。参考答案
fetch只做「下载」:把远程的新提交取到本地,更新origin/main这个远程跟踪分支,完全不动你的工作区和你的分支。pull是fetch之后再自动做一次merge,所以它会改动你的文件,也可能把你直接推进冲突现场。
记住那句话的价值在于:它提醒你pull里藏着一个可能产生冲突的步骤,所以pull不是一个「无害的同步按钮」。
先 fetch 更好的场景:你手上的改动还没提交完,不确定远程上有什么。这时git fetch然后git log --oneline --graph --all可以让你先看清楚别人改了什么,再决定是先提交自己的、还是先合并、还是先切分支。 -
动手题:从零建一个仓库,制造一次冲突,然后解决它。请写出你会敲的命令序列(不必写具体输出,但要写出每一步在做什么,以及冲突时你如何判断该保留哪一边)。
参考答案
一个可行的序列:
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看那张网:应该能看到一个有两个父节点的合并提交。
判断该保留哪一边的依据:不是「哪个分支更对」,而是「这两份内容各自的意图是什么、哪一份符合你现在想要的结果」——如果两边都对,就写出一个两者兼顾的第三版。这就是「冲突需要人的判断」的实际含义。 -
动手题:请为你自己的
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 .之前——事后再补,只对未来的文件有效,已经提交进去的东西不会自己消失。 -
开放题:如果 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,跑一遍那七条固定场景,把结果提交在分支上——然后合不合都行,因为那个结论已经被留下来了。