这一章正式开始讲 Git。这一章的命令并不多,只有 init、status、add、commit、log,再加上一个 .gitignore 文件,照着敲一遍全都能运行成功。但是能运行成功和真正理解是两回事,真正让人困惑的并不是命令本身,而是 git status 的输出为什么是那个样子。
Changes to be committed、Changes not staged for commit 和 Untracked files,同一个文件甚至可能同时出现在其中的两栏里。只有知道了 Git 把内容分成三个地方来存放,才能读懂这些提示。所以这一篇不按命令来组织,而是按地方来组织。先讲清楚有哪三个区域,再看每条命令负责哪一段搬运。弄清楚这一点之后,上面那几行英文就不用再去查了,因为它们说的就是这三个区域的状态。
一、仓库到底是什么
常见的说法是,repository(仓库)是「Git 真正工作的地方」。这句话没错,但是比较抽象。具体来说,仓库是一个实实在在的东西,用 上一章 介绍的命令就能看到:
git init
ls -a
# . .. .git
代码 1:执行 git init 之后多出来的那个目录。
git init 所做的事情,就是在当前目录里创建一个 .git 文件夹,仅此而已。项目里的文件一个字节都没有改动,只是旁边多了一个以点开头、默认看不到的目录。所谓「这个文件夹被 Git 管理起来了」,在物理上的含义就是「它里面有一个 .git 目录」。所有的提交记录、分支和配置都存放在这里面,如果把它删掉,这个文件夹就会立刻变回一个普通的文件夹。
删 .git 前先确认没有未推送的提交,否则历史真的没了。
这里有两件事需要先说清楚。
第一,git init 作用于当前工作目录。 它并不接收「初始化哪个项目」这样的参数,这和上一章的十来条命令是同一个道理。所以在执行 git init 之前,先用 pwd 确认一下位置。如果不小心在家目录里执行了一次初始化,Git 就会开始管理整个用户目录,之后每次执行 git status,都会列出成千上万个未跟踪的文件。这也是仓库不应该嵌套的原因。如果在一个已经是仓库的目录里再执行一次 init,两层 Git 就会互相看不到对方的文件。
第二,第一次使用之前,需要先设置自己的身份信息。 每一次提交都要记录作者是谁,如果没有设置,git commit 会直接失败,而不会随便用一个默认值代替:
git config --global user.name "yourname"
git config --global user.email "you@example.com"
代码 2:--global 表示对这台机器上的所有仓库都生效,所以只需要设置一次。
二、三个地方,不是两个
这是整章的骨架。一次改动从敲下的文字到进入历史记录,中间要经过两道门、三个地方:
工作区(Working Directory) --git add--> 暂存区(Staging Area) --git commit--> 仓库(Repository)
你正在编辑的文件 挑好的、准备提交的内容 .git 里的永久记录
工作区就是能用编辑器打开的那些文件,仓库是 .git 里那份不可修改的历史记录,而夹在两者中间的暂存区是唯一一个新概念。它也是唯一一个本来可以没有、但 Git 偏偏要设计出来的东西。
暂存区存在的理由是,一次改动和一次提交不应该被强行绑定在一起。写代码的时候,很可能同时修改了三个文件,其中两个和正在开发的功能有关,第三个只是顺手调整了一下配置。如果只有「修改了就提交」这两种状态,这三个文件就必须一起进入历史记录,提交记录也就会变得混乱不堪。有了暂存区以后,就可以先挑出那两个文件,把它们提交成一条干净的记录,第三个文件则留在工作区里,等下次再处理。
所以说,暂存区是用来「挑选」的地方,而不是用来「保存」的地方。后面所有命令的行为,都可以从这句话得到解释。
三、git status:读那张地图
git status 是唯一一条能够告诉你此刻每个文件处于哪个区域的命令,而且它的输出正是按照上面说的三个地方来分栏的:
| 输出里的栏目 | 含义 | 这些文件在哪 |
|---|---|---|
Untracked files | Git 从没见过的新文件 | 工作区,且完全没被追踪 |
Changes not staged for commit | 追踪过、改了、但没 add | 工作区 |
Changes to be committed | 已经 add 过,等着提交 | 暂存区 |
nothing to commit, working tree clean | 三个地方内容一致 | —— |
Git 还很贴心地在每一栏下面直接印出了接下来可能要敲的命令。所以在刚上手的阶段,每敲一条命令之后,都运行一遍 git status,这比背命令有用得多,因为它会把每条命令实际搬动了什么直接显示出来。
这里顺便解释一个词,未跟踪(untracked)并不等于被忽略。 它的意思只是 Git 还没有管理过这个文件,一旦对它执行过一次 add,它就变成了被追踪的文件,此后它的每一次改动 Git 都会关注。这个区别到第八节讲 .gitignore 时,会变成一个很大的坑。
四、git add:工作区 → 暂存区
git add notes.md # 暂存单个文件
git add notes.md src/a.go # 一次暂存多个
git add . # 暂存当前目录下的所有改动
代码 3:add 的三种写法。
第三行里的那个 .,就是上一章说的「当前目录」,并不是什么特殊的语法。这意味着两件事。第一,它会递归地包括所有子目录;第二,它只会从当前所在的位置往下查找。如果当前位于 src/ 目录里,那么 git add . 就抓不到仓库根目录下其他地方的改动。如果想不管在哪里都把所有改动都暂存起来,就要使用 git add -A。
add -A 在 Git 2.0 后也覆盖仓库根全范围,和 add . 的差别正是位置。
接下来是这一节真正的重点,也是最容易理解错的一点:
git add暂存的是「文件此刻的内容」,不是「这个文件」。
它相当于拍了一张快照,所以下面这个流程的结果会很违反直觉:
git add notes.md # 暂存 A 版本
# ……接着又改了 notes.md,变成 B 版本
git status # notes.md 同时出现在两栏里
git commit -m "..." # 提交的是 A,不是 B
代码 4:执行 add 之后再修改文件,新的改动并不会自动加入暂存区。
同一个文件同时出现在 Changes to be committed(A 版本)和 Changes not staged for commit(B 版本)两栏里,乍一看像是 Git 出了 bug,但其实它说得完全准确。暂存区里放的是 A,工作区里放的是 B,这是两份不同的内容。 由于提交只认暂存区里的内容,所以提交进去的是 A。
如果想提交 B,只要再执行一次 git add,用新的快照覆盖原来的就可以了。这个坑真正危险的地方在于它不会报错。提交成功了,命令也没有任何异常,只是提交进去的并不是心里以为的那个版本。所以前面那句「每敲一条命令就运行一遍 git status」,在这里是真的能避免出错的。
五、git commit:暂存区 → 仓库
git commit -m "写清楚这次改了什么"
代码 5:-m 是 message 的意思,如果不写它,Git 会打开编辑器让你输入提交信息。
这里只需要记住一条,commit 打包的是暂存区里的内容,工作区里没有执行 add 的改动,一律不会进入这次提交。 上一节那个坑的另一半原因就在这里。
关于提交信息的写法,协作中通行的惯例是用现在时的祈使句描述这次提交做了什么,比如写成 add gitignore for build output,而不是 added... 或者 我加了 gitignore。这样做的理由很实际,因为 Git 自己生成的提交(比如合并提交)用的就是这种语气,保持一致读起来才顺畅。至于内容,写清楚「为什么这样修改」要比写「改了哪些行」有价值得多,因为改了哪些行随时可以用 git diff 查看,而为什么这样修改,只有提交的人在当时才最清楚。
提交完成之后,这次改动就有了一个永久的、以 SHA 哈希值为名字的身份,从此可以随时被找回来。这也是 Git 敢让人放心大胆地修改代码的底气所在,只要提交过,内容就不会丢失。
六、git log:读仓库里的历史
git log
git log --oneline
代码 6:完整的历史记录和精简的历史记录。
git log 会按照时间倒序列出所有提交,每一条都带有完整的 SHA、作者、日期和提交信息。--oneline 会把每一条压缩成一行,只显示缩写的 SHA 和一句提交信息,这样一屏可以看到十几条,适合用来了解最近都做了哪些修改。
这里第一次出现的那串十六进制字符,就是提交的 SHA。它的缩写通常是前七位,日常引用已经足够了。后面撤销、比较、变基这些命令,参数往往就是从这里复制出来的。所以 --oneline 不仅仅是为了排版好看,它还是后面所有「回到某个版本」的操作的入口。
七、git commit --amend:不是修改,是替换
这样的场景很常见:刚提交完,就发现漏掉了一个文件,或者提交信息里打错了字。如果为了这点小事再新建一条提交,历史记录就会变得杂乱,--amend 就是用来处理这种情况的:
git add forgotten.md # 先把漏掉的补进暂存区
git commit --amend # 连同上一次提交一起重新打包
git commit --amend --no-edit # 只补文件,message 保持原样
代码 7:--amend 会把暂存区里的内容并入上一次提交。
不过,「修改上一次提交」这种说法其实只是一个善意的谎言。Git 的提交对象是不可修改的,--amend 实际做的是创建一个新的提交,然后让分支指向它。原来的那条提交并没有被改写,只是不再有任何东西指向它了。最直接的证据就是 SHA 变了,执行 amend 之后再运行 git log --oneline,可以看到最上面那条提交的哈希值和之前已经不一样了。
由此可以直接推出一条硬性规定,已经推送到远程仓库的提交,不要再执行 amend。因为对于其他人来说,那条旧提交仍然存在于他们的历史记录里,而本地换成了一个新的 SHA,两边的历史就会从这一点开始分叉。之后再推送时,Git 会直接拒绝;如果强行推送,就会把别人的记录覆盖掉。所以只对还没有推送出去的最后一次提交使用它,这才是安全的范围。
这条规定对这个博客来说尤其现实。这个仓库里有几个定时任务会自动往 main 分支上提交,所以「远程仓库已经改变了」并不是一个假想的教学例子,而是每天都在发生的事情。因此在执行 amend 之前,应该先确认一下,这条提交是否只存在于本地。
八、.gitignore:把文件拦在第一道门外
有些文件根本就不应该进入版本历史,比如编译产物、依赖目录、日志、系统自动生成的垃圾文件,还有密钥。处理的办法是在仓库的根目录下创建一个名为 .gitignore 的文件(这又是一个以点开头的隐藏文件,要用 ls -a 才能看到),然后在里面每行写一条规则:
.DS_Store # 按文件名忽略:macOS 访达在每个文件夹里生成的那个
secrets.env # 忽略指定的某个文件
node_modules/ # 末尾的斜杠表示只匹配目录,整个文件夹连同内容一起忽略
*.log # 通配符:所有以 .log 结尾的文件
!important.log # 感叹号是反向规则:把上一条排除掉的这个再捞回来
代码 8:几种常见的写法。
还有两个细节,它们都很容易让人出错。
第一,不带斜杠的规则会匹配任意层级的文件。 比如写下 .DS_Store,忽略的是仓库里所有目录下的同名文件,而不只是根目录下的那一个。反过来,如果规则的中间带有 /(比如 build/output),它就会变成一条从 .gitignore 所在目录算起的路径,只匹配那一个位置。
第二,也是那个真正的坑,.gitignore 只对还没有被追踪的文件有效。
一个文件只要被 add 或者 commit 过一次,它就成了被追踪的文件。从此以后,无论 .gitignore 里怎么写,Git 都会照样关注它的改动。这个行为虽然很违反直觉,但它是合理的。因为忽略清单管的是要不要把新的文件纳入管理,而不是要不要把已经在管理中的文件剔除出去。
已追踪文件要先 git rm --cached 移出,忽略规则才生效,别只改 .gitignore。
它的表现就是,明明已经写进了 .gitignore,这个文件却仍然出现在 git status 的输出里。 解决办法是先把它从追踪列表中移除:
git rm --cached secrets.env
代码 9:--cached 表示只从 Git 的追踪中删除,磁盘上的文件会保留下来。
这个 --cached 千万不能漏掉。如果不加它,git rm 会把磁盘上的文件也一起删除;而如果这个文件从来没有提交过,它就真的找不回来了。把文件从追踪中移除之后,再提交一次,.gitignore 才会开始对它生效。
最后还有一点容易忘记,.gitignore 本身是要提交到仓库里的。它是项目配置的一部分,必须让每个克隆这个仓库的人都拿到同一份忽略规则,否则每个人执行 git status 时看到的内容就会不一样。
小结
把这一章的六条命令,按照「它在三个区域之间搬运了什么」重新排列一遍:
| 命令 | 它做的事 |
|---|---|
git init | 在当前目录建 .git,仓库从这一刻开始存在 |
git status | 报告每个文件现在处于哪一区(唯一的地图) |
git add | 工作区 → 暂存区,搬的是内容快照 |
git commit -m | 暂存区 → 仓库,工作区里没 add 的一律不带 |
git log / --oneline | 读仓库里的历史,顺便拿到 SHA |
git commit --amend | 用新提交替换最后一次(SHA 会变,推过就别用) |
.gitignore | 让文件根本不进第一道门(但拦不住已经进去的) |
有两个地方看起来像是 Git 出了错,但其实是理解有误,值得单独记下来:
- 执行
add之后再修改文件,提交的仍然是执行 add 那一刻的版本,因为暂存的是内容,而不是文件; - 写进
.gitignore的文件仍然出现在git status里,因为忽略规则只对未追踪的文件有效。
这两个坑有一个共同点,就是它们都不会报错。命令执行成功,终端上没有任何异常,历史记录也照常往前推进,只是里面装的内容和心里以为的不一样。这大概是使用 Git 时最需要适应的一件事,它几乎从来不会阻止你,只是忠实地记录下实际做了什么。所以 git status 才应该是敲得最频繁的那条命令,因为只有它会把「Git 眼中的现状」和「自己以为的现状」放在一起对照。
下一章开始讲分支,内容包括怎样在不影响主线的前提下开辟一条新的路线,以及分支在 .git 里到底是什么东西。