读书导读 · CC BY-NC-SA 3.0 · 非商业
《Pro Git(精通 Git)》全书摘要与逐章导读
非商业许可:按许可,本页不引用原书文字,只链接回官方免费版本的对应小节。免费阅读原书。
《Pro Git(精通 Git)》讲了什么AI 辅助
一个核心问题,把全书从存储到协作串了起来
整体来看,这本书只问一个问题——基于快照的分布式历史,怎样改变人们协作的方式?——并以层层建立的几个层次回答。它从 Git 的存储模型开始:每次克隆都镜像完整历史,每个提交是一份带校验和的快照,数据几乎只增不减;然后把这个模型转成日常的本地循环:跟踪、暂存、提交,以及读取状态;因为分支只是一个 41 字节的指针,合并要么是指针移动,要么是一个多父提交,这就是使频繁的主题分支切实可行、而不只是可能的原因。推广到他人,它涵盖远程仓库和跟踪分支,共享的裸仓库和暴露它们的传输协议,工作流的菜单——集中式、集成管理员、独裁者与副手——以及 GitHub 的复刻加拉取请求约定。高级内容随后提供手艺:提交的命名和范围,补丁级的暂存,变基及其风险,分层的配置和逐文件的属性,以及通往 Subversion、Mercurial 和 Perforce 的桥。它最后深入命令之下,把 Git 呈现为一个按内容寻址的对象数据库,高层命令不过是把底层操作自动化,同时概览各种图形界面和 shell 集成,这样,从存储模型到本地习惯、到团队协议、再回落到内部原理的这条弧线,使每一层都由下一层来证成。
一则讲透全书的寓言AI 辅助
用一个故事装下全书最核心的想法,并老实交代故事在哪里失真。
塔楼着火时,他们靠分布式解决了备份问题
从前有个织工村,把花样都存在一座上了锁的塔里。只有塔的看守人手里有那本账簿,每个想要昨天花样的织工都得爬上去问。一个冬天,塔烧了,连同村子是怎么学会织布的全部记录。
织工们用不同的办法重建。一个叫萨罗的年轻染匠提议:什么东西都不再按名字或架子上的位置来存放。取而代之,每一块织好的布都浸进一个“读取浴”,从布自己的纱线里得出一个独一无二的印记;这个印记就是这块布的地址。同样的布得到同样的印记,所以村里从不把同一种织法存两遍——只是指向已经有的那块。村里保管三类东西:没有名字的染色纱团,给纱团起名字、定位置的索引板,以及注明日期的证书:它指明一块索引板,指明在它之前的那些证书,还写明是谁织的、谁归档的。什么东西都从不被刮掉;那堆东西只增不减。
然后萨罗做了更奇怪的事:每个织工都得到整堆东西。不是借条,是那一整堆。一百户人家,一百份完整的历史,每一份都是备份,每一份在暴风雪里、哪儿也去不了的时候都能用。
随之而来两个习惯。归档一张证书之前,织工只把她打算记录的纱线摆在织机旁一张低矮的桌上——不是整台织机,只是那张桌子,而且如果她把一根纱线摆上去之后又碰了它,就得再摆一遍。要标记一条工作线索,她不去复制那堆东西,而是写一张短短的纸条,上面只有某张证书的印记和一个名字,轻得一撕就破,所以开启一条新的工作线索不花任何代价。
麻烦还是来了,却不是出在机制上。村子摆脱了塔,却立刻选出了一座新的:一个中央谷仓,大家都同意把证书送到那里,因为一百个对等的人家、一百堆东西,没法就真正的布在哪里达成一致。老坦苏抱怨说,他们把烧掉的东西又重建了。萨罗回答说,谷仓握的权力,各户并不缺——把它烧了,任何一户都能把它重新装满——这与一座塔是不同的。织工们还学到了这堆东西增长时的严厉规则:一张纸条所连的那串证书,只要还在你自己家里,你想怎么改都行,但一旦邻居抄走了它,重写它就逼得每个邻居去理顺他们本以为已经完成的工作。而那个走捷径的“跳桌者”——为省一步,把整台织机一股脑扫到桌上的织工——发现她的证书记下了她从未打算记录的改动,事后也说不清哪根纱线才是重点。
有一样损失是永久的。因为一块布是按它自己的纱线、而不是按它怎样移动的故事来编址的,当一个织工把一个图案从边上挪到中央时,那堆东西只记录两幅画:图案在边上,图案在中央。意图——“我把它挪了”——从未被写下来。后来的织工只能靠注意到纱线吻合来猜它。
故事里的每样东西代表什么
| 故事里 | 书里 |
|---|---|
| 读取浴:从布自己的纱线里,给每块布一个地址 | 按内容寻址的对象数据库:一个以内容本身的哈希为键的键-值存储,所以相同的内容只存一份,被指向而不是被复制 |
| 没有名字的纱团,加上名字的索引板,注明日期的证书——指明一块索引板,加上它的前驱、它的织者和归档者 | 三种层层叠加的对象类型——blob 存内容,tree 加上名字和模式,commit 加上身份、时间和祖先——存储的是快照,而不是差异 |
| 每户人家都持有整堆东西,暴风雪里哪儿也去不了也能用 | 分布式版本控制:每次克隆都镜像完整的历史,所以每一份副本都是备份,大多数操作又快又能离线进行 |
| 织机旁那张低矮的桌子,只放这张证书打算记录的纱线,任何后来的触碰之后都要重新摆放 | 暂存区:git add 在特定的时刻捕获特定的内容,而不是一个文件,所以之后的修改必须重新 add,提交是经过刻意塑造的 |
| 那张轻轻一撕就破的、带着一张证书的印记和一个名字的纸条 | 分支是一个存着提交校验和的 41 字节文件,这就是为什么分支操作几乎是瞬间完成、频繁使用主题分支是切实可行的 |
| 被选出的中央谷仓,和坦苏抱怨塔被重建了 | 这里的张力是:分布式承诺没有必须的中心,但协作又重新立起一个权威的共享仓库——一个任何克隆都能重新装满的裸仓库,而不是一个集中的单点 |
| 在家里可以随意改写纸条,但在邻居抄走之后就不行 | 变基及其风险:重写别人已经持有的提交,逼得他们重新合并,并造成历史重复,这就是为什么对拉取请求的评审,是靠添加提交而不是强制推送来回应的 |
| 为省一步,把整台织机扫到桌上的织工 | -a 这个捷径悄悄地重新引入了暂存区本来要取代的、整个工作树式的提交,代价是可评审性 |
寓言在哪里失真
这则寓言的扭曲之处,在于把整套机制写得像一个村子里一种固定下来的安排,而书的主张是分层的、多元的,故事装不下。它装不下让一堆东西变得可读的查询词汇——status 对 diff,短哈希,引用日志名,祖先运算符,双点与三点范围——所以寓言里的织工们似乎凭直觉就了解自己的那堆东西,而这恰恰是书所否认的。它把合并压扁成一个动作,而不是两个不同的动作(历史线性时是指针滑动,历史分叉时是一个新的多父提交)。它把协作模型的整份菜单——集中式的中心、集成管理员、独裁者与副手、复刻加拉取请求、邮寄补丁——塌缩成一个谷仓,丢掉了这一点:没有哪种模型是事先正确的,而复刻加拉取请求是用命名空间的所有权取代了推送权限。它略去了:暴露共享仓库是一项没有通则的安全权衡(最快的协议是不认证的;智能 HTTP 在同一个 URL 上提供匿名读取和认证推送;完整性要靠服务器端来守卫)。它没有空间容纳分层的配置和逐文件的属性——就近的值悄悄获胜,不透明的二进制格式只有在比较之前先转成文本才可评审——也没有空间容纳通往外部系统的桥,每座桥都强加自己的纪律。最重要的是,这则寓言把它的读取浴说成是显然的事实,而书的论证大部分来自设计上的推理和小小的玩具仓库,而不是测量:速度是靠同复制目录的老工具作对比而断言的,短哈希的安全性靠的是一个项目的一份快照,而不是碰撞的界,而书承认,教人理解模型的那种手工操作对象的功夫,作为习惯是不切实际的。
《Pro Git(精通 Git)》的核心论点
全书捍卫的 10 个论点,按论证需要的顺序排列。
- Git 最好被理解为一个按内容寻址的文件系统,上面叠了一个版本控制的界面:区别于其他系统的,是它的快照存储,而不是命令集;它的三种对象类型层层叠加(blob 存内容,tree 加上名字,commit 加上身份、时间和祖先);日常命令不过是把底层操作自动化;因此没有哪个 GUI 或 shell 集成在能力上超过命令行。
- 因为每次克隆都在本地镜像完整历史,Git 又对所有内容计算校验和、几乎只往里添加数据,大多数操作又快又能离线,已提交的工作很难丢失,这就去掉了集中式系统所强加的历史的单点集中。
- 暂存区意味着 git add 捕获的是特定时刻的特定内容,而不是登记一个文件,所以之后的修改必须重新 add,提交应当被有意地塑造——包括逐个修改块(hunk)地——成逻辑上分开的、可评审的改动。
- 要知道一个仓库里实际有什么,需要的是它的查询词汇,而不是直觉:status 说出哪些文件变了,而 diff 显示行并隐藏已暂存的改动,短哈希、引用日志与祖先名字、双点/三点范围,则回答诸如什么还没合并、一次推送会发出什么这类实际的问题。
- 分支操作几乎是瞬间完成的,因为分支只是一个存着提交校验和的 41 字节文件,这使频繁的主题分支切实可行;因此合并不是一个操作,而是两个:历史线性时是指针移动,历史分叉时是一个新的多父提交。
- 协作没有唯一正确的模型,而是一份按团队规模和访问规则选择、可以混用的菜单,所以你该怎样贡献,无法事先规定——它取决于贡献者的数量、项目的工作流,以及你是否有提交权限。
- 复刻加拉取请求式的协作,用命名空间的所有权取代推送权限,所以维护者从不把规范仓库的写权限交给别人;又因为持久的记录是讨论加最终的合并,评审应当靠添加提交、把目标分支合并进来去回应,而不是靠强制推送一个重写过的系列。
- 协作应当流经一个可靠的共享中间仓库,而不是个人机器之间的直接推送,而选择怎样暴露它是一项安全决定——最快的协议是不认证的,而智能 HTTP 胜出,是因为它在同一个 URL 上提供匿名读取和认证推送——历史的完整性靠服务器端来守卫,而不是信赖客户端的自律。
- Git 的行为应当在合适的范围内配置——分层的配置文件设置默认值,最近的文件获胜——以及在无法逐仓库决定的地方使用逐文件的属性,这就是不透明的二进制格式怎样通过在做差异之前先转成文本而变得可评审,而不是去比较字节。
- 通往外部版本控制系统的桥,只能在那个系统的约束之内工作,所以每座桥都强加它自己在 Git 一侧的纪律:Subversion 和 Perforce 要线性历史,Mercurial 可以自由合并却不能重写历史。
逐章导读
每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。
第 1 章:起步
暂存区怎样改变了一个提交可以含有什么?
第 1 章是怎样展开的AI 辅助
开场:一个按历史提出的问题。 这一章根本不是从 Git 开始的。它从一个一般的问题开始:人们怎样追踪变化中的文件,并按逐步升级的顺序把这个问题回答三遍——本地版本控制,然后是集中式,然后是分布式。这个顺序是论证性的,而不仅仅是时间上的。每个系统都被引入,作为对前一个的修补,而集中式模型得到最长的陈述,恰恰因为本章在这里埋下它的核心不满:把全部项目历史放在一台服务器上,把风险集中在一个地方。作者具体地算出了这要付出什么代价——服务器停机一小时,就是一小时里没人能协作或保存有版本的改动,而一块损坏的磁盘,如果没有妥善备份,会带走整个历史。分布式模型随后到来,不是作为新奇事物,而是作为对读者已被引导去感受的一个问题的回答。只有在这番铺垫之后,Git 才以名字出现,带着它 2005 年的起源故事,这个故事与其说是为历史本身,不如说是证明这个设计是在真实的约束下锻造出来的。
展开:从架构到数据模型。 确立了分布式设置里每个客户端都镜像整个仓库之后,本章把这一事实兑现成行为。因为克隆是一份完整的备份,历史和与旧版本的比较,是从本地磁盘读出的;读日志或对照一个月前的文件做差异,不需要一次网络往返,服务器被降为一个共享机制。这就是为什么提交在飞机上也能进行。从这里起,本章提出它最尖锐的主张:使 Git 与众不同的,不是它的命令,而是它怎样思考数据。Subversion 风格的系统保存一组文件,加上随时间对每个文件所做的变更流;Git 在每次提交时记录所有文件的一份快照。作者小心地在同一口气里化解了那个显而易见的反对——没有改变的文件不会被再次存储,而是链接到数据库里已有的相同内容——这样,画面的完整和字节的重复,保持为两个不同的想法。完整性随后以同样的口吻到来:内容先计算校验和,再以该校验和来引用,存在一个四十个字符的 SHA-1 哈希之下,而不是一个文件名之下,而且几乎每个操作都只往数据库里添加东西。这两个事实合起来,就是本章许诺“已提交的工作很难在 Git 不察觉的情况下丢失或损坏”的凭据。三种状态和项目的三个区域,结束了这一块概念内容,给读者一套词汇,带进后面的命令。
转折:从理解到动手。 铰链出现在本章不再解释、开始指示的时候。安装和第一次 git config,标志着从模型转向可用的工具,口气相应地改变——简短、规定性的、机器层面的。身份设置先来,因为没有它们,别的什么也用不了。然后是 init.defaultBranch,本章严格地限定它的范围:它从 Git 2.28 起才有,它命名的是仓库初始化时创建的第一个分支。已经存在的仓库原封不动。这种谨慎是本章方法的特征——它一再把一种能力同其作用范围的确切边缘配成对。
收束:学会盘问,而不是猜测。 最后一个运动是关于自给自足。配置可以整体用 --list 检视,缩小到单个键,或者——最有启发的选项——用 --show-origin 追溯。作者解释为什么这要紧:同一个键可以合法地住在好几个配置文件里,Git 使用最后读到的那个,所以列表里的重复是意料之中的,而不是坏了。比如,询问 rerere.autoUpdate 的来源,会同时打印出决定它的文件路径和值 false,径直说出获胜的文件。这个教训超出了配置之外:当一个值让你意外时,追溯它。本章随后以指引读者去看内置的帮助收尾——git help <动词>、--help、man 手册页,或者用 -h 看简短的选项表——全都可以离线使用。这个结尾优雅地呼应了开头。一章开头是在论证不要依赖远程服务器,结束时表明连文档也已经在你的磁盘上了。
关键论点,以及支撑它们的原文
把全部项目历史保存在一台中央服务器上,把风险集中在一个地方。
作者推理单点故障的代价:如果服务器停机一小时,这一小时里没人能协作,或保存任何有版本的改动,而如果它的磁盘损坏又没有妥善的备份,整个历史就没了。这是本章最先布下的问题,这样分布式版本控制就可以作为对它的回答被引入。
因为每次克隆都镜像整个仓库,大多数 Git 操作都很快,并且无需联网。
紧接着对集中式问题的修补,作者指出一些具体的操作:浏览项目的历史,或把当前的文件同它一个月前的状态做差异,都是直接从本地磁盘读出的,而不是从服务器取来的,提交在飞机上、不用 VPN 时也能进行,分享推迟到连上网络之后。
真正把 Git 同其他版本控制系统区别开的,是它怎样思考数据,而不是它的命令。
作者对比两种存储模型:Subversion 风格的系统保存一组文件,加上随时间对每个文件所做的改动,而 Git 在每次提交时记录所有文件的快照,对没有改变的文件,只存储一个指向已存储的相同内容的链接。主张是:Git 之所以这样行事,是这个模型的区别,而不是表面界面的区别。
另外 2 个论点
Git 被造成这样:你已提交的工作很难丢失,或在不被察觉的情况下损坏。
两个设计事实支撑这一点:内容先计算校验和,再以该校验和来引用,所以 Git 在数据库里以 40 个字符的 SHA-1 哈希而不是文件名存东西,而几乎所有的动作都只往数据库里添加数据。合起来,它们意味着一旦有了提交,未被察觉的损坏和意外的毁灭都不太可能。
当一个配置值让你意外时,追溯是哪个文件设置了它,而不是猜。
作者指出,同一个键可以合法地出现在几个配置文件里,Git 实际使用的值是最后读到的那个,这就是为什么列表里会出现重复。你不必猜,可以向 Git 询问来源:查询 rerere.autoUpdate 的来源,会同时打印出决定它的文件路径和值 false,所以获胜的文件被明确地说了出来。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
每次克隆都是完整的备份
在分布式设置里,每个客户端都把整个仓库,包括历史,复制到自己的磁盘上。因此读日志或对照一个月前的文件做差异,都是在本地计算的,没有网络往返,服务器只在分享工作时才需要——这就是为什么在飞机上也能提交。
机制 2
复用文件的快照
Git 的每个提交不是记录逐文件的变更清单,而是保存那一刻每个文件的一幅画面。为保持高效,没有改变的文件不会再次存储;快照只是链接到数据库里已有的相同副本,所以完整性花的空间很少。
机制 3
校验和加上只增不减的写入
一切在存储之前都计算校验和,并以它的 40 个字符的 SHA-1 哈希而不是文件名来编址,所以内容的任何悄悄改变,都不再与它的名字吻合。因为几乎每个动作都只添加数据,从不丢弃,已提交的工作很难在 Git 不察觉的情况下丢失或损坏。
常见误读
每次提交都存一份完整的快照,一定意味着 Git 保存了从不改变的文件的重复副本,所以比只记录差异的系统浪费得多的空间。
快照在它所描述的东西上是完整的,而不是在它重新存储的东西上:当一个文件没有改变时,Git 不会再次存储它,而是记录一个指向数据库中已有的相同内容的链接。画面的完整和字节的重复,是两回事。
一旦你全局设置了默认的初始分支名,你的仓库就会用这个分支名。
这个设置只在它能起作用的地方起作用:从 Git 2.28 起才有,它命名的是新仓库初始化时创建的第一个分支。已经存在的仓库不受影响,所以它们的分支名保持原样。
第 1 章的核心概念
- 版本控制
- 一个随时间记录文件改动、使以前的版本可以被取回的系统。
- 适用处: 这里用源代码来说明,但几乎适用于任何文件类型。
- 分布式版本控制
- 每个客户端都镜像整个仓库,包括它的完整历史,所以每次克隆都是一份备份。
- 适用处: 消除了单点故障,但分享工作仍然依赖服务器。
- 快照而非差异
- 每个提交保存所有文件的一幅画面,对没有改动的文件只链接而不重复存储。
- 适用处: 与 CVS、Subversion 和 Perforce 这类基于差分的系统形成对照。
- 三种状态
- 一个文件是已修改、已暂存或已提交,分别对应工作树、暂存区和 Git 目录。
- 适用处: 暂存这一步可以跳过,如“Git 基础”所解释。
- git config 的层级
- 系统、全局和本地的配置文件,每一层都覆盖前一层。
- 适用处: 本地是默认层级,要求处在一个仓库之内。
- init.defaultBranch
- 一个全局设置,命名 git init 创建的第一个分支,取代默认的 master。
- 适用处: 只有 Git 2.28 以后才有,并且只影响新创建的仓库。
另外 2 个概念
- 后值覆盖前值的配置
- 同一个键可以出现在几个配置文件里,Git 使用的值,是它最后读到的那个。
- 适用处: 解释了 --list 输出里键的重复;要看是哪个文件决定的,用 --show-origin。
- Git 手册页帮助
- 打开任何 Git 命令的完整手册的三种等价方式,外加用 -h 看简短的选项摘要。
- 适用处: 随处可离线使用;说到人工帮助,书则指向 IRC 频道。
读后感AI 陪读
Git 的手册,是先转化人,再教人
当作修辞而不是教学来读,这一章是一篇穿着手册衣服的皈依叙事。前三分之一根本没有在教 Git——它造了一道梯子(复制目录→RCS→CVS/Subversion→Git),每一级的弱点,就是下一级的理由,所以当 Git 到来时,它让人觉得与其说是被发明的,不如说是被推演出来的。枢纽是这样一个主张:Git 存的是快照而不是差异,这在概念上多于在物理上(文本自己承认有去重,而包文件确实做差分压缩),但这是正确的“给孩子的善意谎言”:它为以后的分支模型买单。这一章做得格外好的,是把先前的知识点名为一种隐患——“清空你的头脑”是这里最有用的一句话,承认了 Git 熟悉的动词,坐在陌生的机制之上。它做得较差的,是对它的安全和完整性许诺留有余地:“很难让系统做出任何不可撤销的事”,对对象数据库是真的,对一个初学者的下午却是假的,而 SHA-1 被说成一种保证,其实它是一个事故探测器。年龄在两个地方显现——坚持只用命令行,以及关于默认分支名的那句一笔带过,在那里,本章最平淡的文字,覆盖了最有争议的改变。还有一处书从未提及的结构性反讽:它很用力地论证分布消除了单点故障,然后把读者送入一个每个人的单点故障都是同一个网站的世界。不过,三种状态那一节配得上它抬高的嗓门——它是唯一一个跳过之后、其后的一切就变得不连贯的部分。
在 FeedLitra 里读第 1 章,每段旁边都有这些卡片。
开始读书第 2 章:Git 基础
提交的快照同暂存区里存放的东西是什么关系?
第 2 章是怎样展开的AI 辅助
开场:确立一个起点。 这一章把整个“使用 Git”的问题,收窄成路上的一个岔口:要么把一个已有的目录变成仓库,要么复制一个已经存在的。git init 产生一个 .git 子目录,里面放着仓库的内部骨架——要紧的是,此时还没有任何东西被跟踪——而克隆是从服务器拉下几乎整个历史,而不是只拉某个版本的文件快照。这是一个有意极简的基础,此后的一切都建在它上面:一旦你有了仓库,剩下的唯一问题,就是内容怎样进去。
展开:生命周期,以及对 add 含义的精确。 从这个基础出发,本章转向文件的生命周期——已跟踪对未跟踪,已暂存对未暂存——以 git status 作为一路读出你身处何位的工具。这里关键的论证是:git add 应当被读成“把这个内容精确地记录下来,供下一次提交”,而不是“把这个文件添加到项目”。演示就是证明:作者暂存 CONTRIBUTING.md,再次编辑它,status 随后把同一个文件同时列在“已暂存”和“未暂存”两个标题之下。暂存区捕获的是一个时刻的内容,而不是对文件的实时链接,所以需要第二次 add,来刷新提交实际将含有的东西。对精确的同样坚持,也支配着对 diff 的处理:status 告诉你“哪些”文件变了,diff 打印确切变了的行,而光秃秃的 git diff 有意回答的是更窄的问题:“尚未暂存”的编辑——要看排了队的是什么,需要 --staged(或 --cached)。没有这个说明,读者会错误地以为 diff 报告的是自上次提交以来的一切。.gitignore 和 commit 完成了这一段,只有在仔细的版本确立之后,作者才承认那个捷径:commit -a 完全跳过暂存,这很方便,但因为它把每一处已跟踪的修改都扫了进去,它可能悄悄带上你本不想记录的改动。这个让步被框成用控制换速度,而不是一种改进。
转折:从造历史到修复历史。 本章随后从记录工作,转向回头触及它——移除和移动文件,用 git log 的格式和限定选项读历史,以及用 --amend、git reset HEAD、git checkout -- 和较新的 git restore 撤销。有两处对直觉的纠正,承担了这一节。第一,git mv 并不意味着 Git 存了重命名的信息;Git 一点也不存,而是事后从内容的相似性推断发生了重命名——mv 纯粹是“移动加移除加添加”的方便。第二,--amend 不是一张通用的安全网:它用当前暂存区造出一个“全新的”提交,并抛弃旧的,所以只有当那个提交还是本地的时候才合适,因为一旦共享出去,替换品就不再与协作者所持有的相符。在这两者之下,是作者明确指出的那种不对称:已提交的工作几乎总能挽救,因为一个提交存了一份完整的副本,但丢弃本地改动是不可逆的。恢复一个文件,只是用上次暂存的或提交的版本覆盖它——而本地的编辑从来没在任何地方被做过快照,所以没有东西可以把它们恢复回来。他对修改过的 CONTRIBUTING.md 运行 git restore,之后只有改了名的 README 仍是已暂存的,那个编辑就这么没了。因此有了警告:只在你想让这些改动被毁掉的时候才用它。可喜的是,git status 自己的提示,被展示成教这套词汇的东西——restore --staged 用来取消暂存,restore 用来丢弃。
向外扩展:远程和标签,以及 Git 拒绝自作主张。 本地工作有了保障,协作就登场了:列出、添加、抓取与拉取、推送、检视、重命名和移除远程。组织性的主张是:Git 不会主动在机器之间或分支之间搬动任何东西。从保罗的远程抓取,只是让他的工作在本地作为一个单独的引用 pb/master 可用,你自己的分支原封不动,这样你可以在自己选择的时候检视或合并;合并是有意的第二步,而 git pull 只在分支设置为跟踪一个远程分支时,才把两者合在一起。打标签——轻量对附注,事后补打,分享,删除,检出——从另一个角度说明了同样的一点:push 不发送标签,除非你要求,所以 git push origin --tags 才是传输服务器上没有的标签的办法。下载不是合并,而在本地拥有某样东西,也不是已经发布了它。
收束:方便,与一次交接。 本章以通过 git config 设置的 Git 别名收尾,包括这个细节:别名可以用前导的 ! 调出外部命令——例子是把 git visual 别名为 gitk。这是个贴切的最后一笔:在一章坚持显式性之后,别名让你压缩冗长,而不放弃任何控制。结尾的评估是,读者现在可以做所有基本的本地 Git 工作,下一步是分支——这就把本章定位为基础,而不是终点。
关键论点,以及支撑它们的原文
开始使用 Git 只有两种办法:把一个已有的目录变成仓库,或复制一个已经存在的。
作者展示 git init 产生一个 .git 子目录,其中放着仓库的内部骨架,而此时还没有任何东西被跟踪,并把它同克隆配成对,克隆是从服务器拉下几乎整个历史,而不是只拉某一个版本的文件。这就为此后的一切定下了起点。
接着这个起点,git add 应当被读成“把这个内容精确地记录下来,供下一次提交”,而不是“把这个文件添加到项目”。
作者暂存 CONTRIBUTING.md,再次编辑它,并表明 status 随后把同一个文件同时列在“已暂存”和“未暂存”两个标题之下——证明被捕获的是一个时刻的内容,而不是文件本身,所以需要第二次 add。暂存区的概念,使这种读法显得自然。
status 和 diff 回答不同的问题,而普通的 git diff 有意回答的是较窄的那个。
作者指出,status 说出哪些文件变了,而 diff 打印确切变了的行,然后演示:光秃秃的 git diff 只显示尚未暂存的编辑,所以要看排了队等着提交的是什么,需要 git diff --staged(或 --cached)。没有这个说明,读者会以为 diff 报告的是自上次提交以来的一切。
另外 3 个论点
坚持了这种精确之后,作者承认了一个捷径——commit -a——同时警告它要你付出控制的代价。
他提交 CONTRIBUTING.md,根本没有 git add 这一步,来展示方便,然后指出 -a 把每个变了的文件都扫了进去;因为它涵盖所有已跟踪的修改,它可能悄悄带上你本不想记录的改动。
从造历史转向修复历史:已提交的工作几乎总是能挽救,但丢弃本地改动是不可逆的。
作者推论:检出或恢复一个文件,只是用上次暂存的或提交的版本覆盖它,并通过对修改过的 CONTRIBUTING.md 运行 git restore 来演示,之后只有改了名的 README 仍是已暂存的,那个编辑就这么没了。因此他明确警告:只在你确定想让这些改动被毁掉的时候才用它。
当协作登场时,Git 不会主动在机器之间或分支之间搬动任何东西。
从保罗的远程抓取,只是让他的分支作为 pb/master 在本地可用,之后由你选择合并或检视;同样,push 不发送标签,除非你要求,所以 git push origin --tags 才是传输服务器上没有的标签的办法。两种情形说明了同样一点:行动是显式的。
§2.8 Remote repositories can be on your local machine.阅读这一节 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么暂存在 add 时冻结内容
暂存区是一个存放区,在你运行 git add 的那一刻记录文件的内容,而不是对文件的实时链接。所以如果你随后又编辑,存下的副本仍反映较旧的版本,文件就同时显示为已暂存和已修改;只有再次 git add,才能刷新提交将含有的东西。
机制 2
为什么未提交的编辑一去不复返
Git 能重建它已经做过快照的任何东西,因为一个提交存了一份完整的副本。丢弃改动的办法,是用上次暂存的或提交的版本覆盖工作文件,而因为本地的编辑从来没在任何地方被做过快照,就没有东西可以把它们恢复回来。
机制 3
为什么下载不是合并
抓取把协作者的工作复制到单独的本地引用里,比如 pb/master,你自己的分支原封不动,这样你可以在自己选择的时候检视或合并它。所以合并是有意的第二步;git pull 只在分支设置为跟踪一个远程分支时,才把两者合在一起。
常见误读
既然有 git mv 命令,Git 一定保留了一份记录:文件 A 被重命名为文件 B。
Git 根本不存任何重命名的信息;它是事后从内容的相似性推断发生了重命名。git mv 纯粹是个方便,做的事同移动文件、移除旧路径、添加新路径是一样的。
既然修补只是替换最后一个提交,那么对任何最近的错误,它都是一个安全的清理手段。
第 2 章的核心概念
- 已跟踪的与未跟踪的文件
- 已跟踪的文件,是 Git 从上一次快照或暂存中已经知道的文件;未跟踪的文件,是工作目录里其余的一切。
- 适用处: 只适用于工作目录里的文件,不适用于 Git 有意隐藏的被忽略的模式。
- 暂存区
- 一个存放区,你在那里确切地组装下一次提交将要记录的内容。
- 适用处: 它在 git add 的那一刻给内容拍快照,所以之后的编辑在你再次 add 之前不会包含进去。
- 作为完整副本的克隆
- git clone 拉下服务器几乎所有的数据,而不只是某个版本的工作副本。
- 适用处: 服务器端的钩子这类不受版本控制的数据不包括在内。
- .gitignore 的模式
- 一个通配符模式的文件,告诉 Git 哪些文件要跳过、不添加或不作为未跟踪的报告。
- 适用处: 嵌套的 .gitignore 文件只适用于它们自己目录之下;细节留给 man gitignore。
- 暂存区的捷径(-a)
- 提交的一个标志,自动暂存所有已跟踪的已修改文件。
- 适用处: 只涵盖已跟踪的文件,并可能扫进不想要的改动。
- 隐式的重命名检测
- Git 不存重命名的元数据;它事后从内容推断重命名。
- 适用处: git mv 只是 mv + rm + add 的方便。
另外 8 个概念
- 作者与提交者
- 作者写出了这项工作;提交者最后应用了它。
- 适用处: 主要在基于补丁的贡献流程里要紧。
- 修补一个提交
- 用当前暂存区造出的一个新提交,整个儿替换最后一个提交。
- 适用处: 只对本地的、未推送的提交是安全的。
- 远程仓库
- 你的项目的一个托管版本,你往里推送、从中拉取;“远程”只意味着在别处,可能就是同一台机器。
- 适用处: 不要求在网络或互联网上。
- 附注标签
- 一个完整的 Git 对象,存储打标签者、日期、信息、校验和,可用 GPG 签名——不同于轻量的指针式标签。
- 适用处: 轻量标签不保留额外信息;只用于临时的标记。
- 分离头指针
- 检出一个标签之后的状态,其中新的提交不属于任何分支,只能靠哈希到达。
- 适用处: 打算做改动时,创建一个分支来避免它。
- Git 别名
- 配置里定义的一种速记,Git 用它替换更长的命令,包括像 unstage 这样自创的。
- 适用处: 以 ! 开头,则运行外部命令,而不是 Git 子命令。
- 外部命令别名
- 一个运行非 Git 程序的别名,用前导的感叹号写出。
- 适用处: 只在目标是外部工具、而不是 Git 子命令时才需要。
- 分支模型
- Git 处理并行的工作线索的方式,被点名为下一个话题。
- 适用处: 这一段里没有解释;只是预告。
读后感AI 陪读
Git 宽恕已提交的,对未提交的却毫不留情
作为一个整体来读,“Git 基础”不是一本碰巧在教一套哲学的命令参考——而是一套碰巧以命令的形式出现的哲学。本章的弧线是有意的:获取一个仓库,学习一个文件的四种状态的生命周期,记录快照,把记录读回来,撤销,同其他仓库对话,标记值得命名的点,最后用别名重新命名工具本身。每个阶段,都悄悄地重述第一段里的同一个论题:克隆是复制而不是借用,所以一个 Git 仓库是一个完整的、自足的对象。本章结尾说,你现在能做所有基本的本地工作——而“本地”这个词就是要点。到这里为止的一切,都不需要服务器就成立。
我看透的是,本章是围绕一条边界组织的,几乎每一项安全特性、警告和道歉,都是对它的重述:已提交与未提交之间的那条线。在线之上,Git 宽容得近乎荒谬——修补可以重写,重置可以重新定位,历史是你精心编排的故事,而不是你不断累积的日志。在线之下,Git 是冷酷的,书也直说了:从没提交过的东西,很可能再也见不到了。不带 -f 就拒绝删除未暂存的改动,restore 的破坏性,最后一个提交的可编辑性——这些不是不同的话题,而是同一个寓意的三种面貌。提交不是保存一个文件;而是被接纳进受保护的世界。
继续阅读
我看透的第二点是,Git 一贯把世界建模成内容加关系,而不是有传记的对象。它不跟踪重命名;它事后从相似性推断,而 git mv 在正文里自己就被承认是穿着风衣的三条命令。“远程”被宣布为“别处”,而不是“远方”——一个关系,而不是一段距离,而这恰恰是分散式为什么能行得通。连暂存也被从它自己的名字里重新解读出来:添加这个“内容”,而不是添加这个“文件”。本章必须做出这个更正,因为这个动词在撒谎,而每个困惑的初学者都是这个命名的受害者。词汇失灵的地方,书递过来的是一个心智模型;这种替换,才是本章真正的教学方法。
不过,它最尖锐的设计想法,不在 Git 里,而在 Git 怎样说话。git status 既是诊断,也是手册——告诉你身在何处的命令,同时也告诉你怎样出来,而这些提示在不同版本之间明显变了,这使 status 成了活的教学表面,而不是静态的文字。文字两次称赞 Git “乐于助人”,这个称赞是一次无意的坦白:Git 的状态有太多是看不见的,你需要一条命令去告诉你另一条命令将要做什么。同样的诚实,也体现在那条推着你去声明用合并还是变基的 pull 警告里——承认 git pull 一直是披着一个名字的两个操作,而 fetch 把“接收”与“接受”分开的那一句话,是大多数人略过、随后为之受苦的。还有本章唯一一个带道德色彩的词:非快进的推送被“理所当然地”拒绝。作者知道在那里被拦住感觉不公平,所以文字从描述机制滑向为之辩护。
我最尖锐的判断是:本章最强的时候,是在化解之前先挣得困惑——暂存一个文件,编辑它,让你发现它被同时列为已暂存和未暂存,然后问“这怎么可能?”——最弱的时候,是让参数表充当理解。日志一节就是测试案例。好几页的选项只是词汇;论证是在一行里到来的,当一次对 40,000 个提交的查询返回 6 个的时候。这才是查询语言给人的感觉,它本该放在最前面。同样,--pretty=format 的真正理由不是美观,而是契约的稳定:默认的输出是界面,而界面会漂移。作者/提交者之分,同样被低估了——它编码了一个社会事实:写出工作和应用工作,是不同种类的功劳,这个字段的存在,是因为邮件补丁的文化,而不是文件系统。而 Linux 内核带有 206 个 .gitignore 文件这条旁白,一个数字就告诉你,忽略本身就是一门分布式的、被维护的纪律。
我想对读者说的是:不要把这一章当作要记忆的命令清单来读,因为命令是其中最不持久的部分。要为那三个十年后仍然承重的想法而读——克隆是一份完整的副本,已提交/未提交的边界是安全开始和结束的地方,Git 存的是内容,同时对它推断故事。早提交、常提交,因为提交很廉价,而未提交的是 Git 唯一无法还给你的东西。学会把 git status 当散文而不是噪音来读;它是这个系统里设计得最好的文档。即使 -a 在诱惑你,也要保留暂存区,因为能够组合一个提交,就是能把你的工作清楚地、而不只是准确地讲真话。现在就花五分钟安静地弄懂 fetch 与 pull 的区别,而不是以后惊慌失措时才去弄。当一个命令的名字误导你时,相信书提供的心智模型,而不是 Git 选的那个词——本章自己的方法是用好的图景替换坏的名字,而这就是值得带进分支一章的习惯,在那里,图景更重要。
在 FeedLitra 里读第 2 章,每段旁边都有这些卡片。
开始读书第 3 章:Git 分支
快进合并与三方合并,在它们留下的历史图里有什么不同?
第 3 章是怎样展开的AI 辅助
开场:一个兼作论证的定义。 本章从最小的单位开始——分支是一个存着一个提交校验和的 41 字节文件,是架在一串快照之上的可移动指针。作者的框架动作是定义这个东西,并让定义承载论战:老的工具造一个分支,靠把整个项目复制到第二个目录里,这在大型代码库上要花几秒或几分钟,而 Git 只写一个指针,所以这个操作是瞬时的。本章此后的一切都建在这上面。HEAD 被引入,作为第二个指针,它指明你在哪个分支上,这解释了一件否则令人吃惊的事实:切换分支会重写磁盘上的文件:移动 HEAD,迫使 Git 让工作目录与新分支的最后一份快照相符。因此也有了那道防护——当未提交的编辑会被覆盖时,Git 拒绝切换,你要靠储藏或修补来清出路。
展开:指针模型生成了行为。 有了机制,作者推导出合并,而不是宣布它。如果你要并入的分支恰好在你所站位置的前面,就没有什么要协调的,Git 只是把指针滑过去——hotfix 的合并打印出“Fast-forward”。如果两条线分叉了,你的提交不是另一个顶端的祖先,Git 就必须把两份快照同它们的共同祖先合在一起,并记录一个有两个父提交的提交——iss53 的合并报告的是 recursive 策略。冲突是第二种情形的残余,靠手工解决。同样的廉价论证随后扩大成一种习惯:iss91、iss91v2 和 dumbidea 的实际操作序列,把一次性的主题分支展示为日常的普通做法,包括干脆放弃一条工作线索。本章对这种自由的代价是诚实的——删除一个分支,丢弃的是使提交保持可达的那个指针,所以 Git 以“not fully merged”拒绝这次删除,只有强制的变体才通得过,这恰恰就是 iss91 的例子怎样丢掉两个提交的。
向外转:从一台机器到许多台。 本章随后从本地仓库扩展到共享的仓库,口气从机制转向协调。重命名默认分支是最清楚的例子:master 在 Git 里没有特权地位——仓库有一个,是因为 init 创建了它而没人去改名——所以 master→main 不是一条表面的命令,而是一个更新依赖项目、测试和构建配置、托管方的设置和文档、关闭或重新指定拉取请求、并且只在新名字得到验证之后才删除旧名字的工程。远程跟踪分支是另一个纠正。分支列表从不接触网络;它报告的是 Git 在上一次联络时记录的东西,所以你看到的关于远程的一切,是一幅缓存的画面,而不是它的当前状态。抓取,把 origin/serverfix 作为一个不可移动的标记拉下来,标记服务器所处的位置,而不是你能在上面工作的东西——要做工作,你得以它为基础建一个本地的跟踪分支。推送、拉取和删除远程分支,都出自同一个区分:本地指针与缓存指针之间的区分。
收束:变基,先被赞美,然后被划界。 最后一个运动把变基引入,作为合并的替代,并以本章已确立的重放改动的习语来解释它:取自共同祖先以来的每个提交,把它保存为一个改动,把分支移到目标的顶端,按顺序重新应用。内容同合并相符;不同的是形状——一条直线,之后目标靠移动一次指针前进。然后本章把赞美掉了个头。如果你重写了已经推送、且有人在其上建构的提交,你的协作者下一次拉取,就会把你那些被重写的提交的旧版本拖回来,日志里同样的改动出现两次——恰恰是他们打算丢掉的工作。给出的是部分的补救,而不是根治:patch-id 是只针对一个提交所引入的改动的校验和,所以 git pull --rebase 可以比较 patch-id,丢掉其改动已存在于上游的提交,只重放真正新的——只有当被重写的补丁保持几乎相同时才可靠。
落点。 本章拒绝给出胜者。合并与变基被呈现为对历史的两种态度——对发生了什么的事实性记录,对造出了什么的可读故事——而因为团队和项目各不相同,没有通用的裁决可用。作者提供的是一条边界:用变基整理本地的工作,但决不要对任何已经离开你机器的东西变基。这条规则让你既保持刚才演示过的整洁,又避免刚才演示过的协作破坏,并以它开头时的同样逻辑结束本章——分支是廉价的、可塑的,一直到它们被共享的那一刻为止。
关键论点,以及支撑它们的原文
Git 里的分支实际上是免费的,因为分支不过是一个存着一个提交校验和的小文件。
作者拿 Git 同老工具对比,后者造一个分支,是把每个项目文件复制到第二个目录里,这在大项目上要花几秒或几分钟;而 Git 只写一个 41 字节的指针,所以这个操作永远是瞬时的。
§3.3 Switching branches changes files in your working directory阅读这一节 →
接着这个指针模型,合并有两种形状:历史是直线时,把指针往前滑;分支分叉时,创建一个有两个父提交的提交。
把 hotfix 合并进 master 打印出“Fast-forward”,因为被合并的提交正好在 master 的提交前面,所以只有指针动了;而合并分叉的 iss53 分支,报告的是用 recursive 策略做的合并,因为当前分支的提交不是另一个顶端的祖先,Git 得把两份快照同它们的共同祖先合起来。
因为分支不花任何代价,一次性的主题分支成了一种日常的习惯,而不是特殊的场合,在任何规模的项目里都是如此。
iss91、iss91v2 和 dumbidea 的实际例子展示了回报:作者放弃了最初的 iss91 这条线(放弃了其上的两个提交),然后按他喜欢的任何顺序合并其他两个分支——这个序列,只有在创建和丢弃分支都廉价时才合理。
另外 3 个论点
你看到的关于远程的一切,只是你上一次网络联络时的缓存画面,而不是远程的当前状态。
作者指出,分支列表从不联系服务器;它报告的是 Git 在本地记录的东西。随后一次抓取拉下指针 origin/serverfix,然而没有可编辑的 serverfix 分支可以工作——只有一个不可移动的标记,标出服务器所在的位置,你必须以它为基础建一个本地的跟踪分支。
把对变基的赞美掉过头来:重写你已经推送过的提交,会给每个在其上建构的人造成历史重复和痛苦的重新合并。
作者走过一个场景:一个搭档变基并强制推送:你下一次拉取,产生一个合并提交,把你那些被重写的提交的旧版本拖回上游,日志里同样的改动出现两次——搭档显然不想重新引入的工作。
作者没有给合并或变基加冕,而是确定了一条边界规则——用变基整理本地的工作,但决不要对任何已经离开你机器的东西变基。
他把这个选择框成两种对待历史的态度,事实性的记录对可读的故事,并论证因为团队和项目各不相同,没有通用的裁决;关于未推送提交的规则,让你得到变基的整洁,而不承受刚才演示过的协作破坏。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么切换会重写你的文件
HEAD 是指向你所在分支的指针,所以检出另一个分支,会移动 HEAD,然后重写你工作目录里的文件,使之与那个分支的最后一份快照相符。因为这些文件必须被替换,当未提交的编辑会被毁掉时,Git 阻止切换;储藏或修补那个提交,就清出了路。
机制 2
变基怎样重放提交
变基取自两个分支共同祖先以来的每个提交,把它保存为一个改动,把分支挪到目标的顶端,并按顺序重新应用这些改动。结果是一条直线的提交,其内容同合并相符,然后目标分支可以靠简单地移动一次指针前进。
机制 3
Git 怎样跳过它已有的补丁
patch-id 是只针对一个提交所引入的改动的校验和,而不是针对提交本身的。当你变基到一个别人重写并强制推送过的分支上时,Git 比较 patch-id,丢掉你那些其改动已存在于上游的提交,只重放真正新的——这只在被重写的补丁保持几乎相同时才行得通。
常见误读
master 是一条享有特权的主干,Git 自己把它当作真正的主线,所以重命名它不过是一条命令的事。
master 在 Git 里没有特殊的地位——几乎每个仓库都有一个,只是因为 init 命令创建了它,而人们很少去改名。重命名默认分支是一项协调工作:它会破坏集成,所以你还得更新依赖的项目、测试运行器和构建配置、托管服务的设置和文档,关闭或合并指向旧名字的拉取请求,并且只在新分支得到验证之后才删除旧分支。
既然分支只是指针,删除一个分支就是无害的簿记,而且删除命令总会照办。
删除会丢弃使提交保持可达的那个指针,所以 Git 防着你丢失工作:作者想删除 testing 分支,失败了,报错说它没有完全合并,只有这个命令的强制变体才通过——这恰恰就是 iss91 的例子怎样丢掉它的两个提交的。
第 3 章的核心概念
- 分支模型
- Git 的分支方式:移动小小的指针,而不是复制文件,所以分支和合并是廉价而频繁的。 一个团队怎样安排分支:稳定的长期运行的线,加上逐级“毕业”的短命主题分支。
- 适用处: 描述本地分支指针和合并机制,不涉及远程或变基工作流。 多个长期运行的分支是可选的,在大项目上最有用。
- HEAD 指针
- 一个特殊的指针,指明你当前所在的本地分支。
- 适用处: 不同于 Subversion 或 CVS 里的 HEAD;这里它指向一个本地分支。
- 快进合并
- 当被合并的提交正好在前面时,Git 只是把分支指针往前滑,不产生合并提交。
- 适用处: 只在没有分叉的工作时适用;否则会创建一个三方合并提交。
- 三方合并提交
- 对分叉分支的一次合并,使用两个顶端和它们的共同祖先,产生一个有多个父提交的提交。
- 适用处: 当双方都改了文件的同一部分时,不能自动完成。
- Git 内部原理
- Git 把内容存储为快照:blob 存文件内容,一棵 tree 列出它们,一个 commit 指向那棵树及其父提交。
- 适用处: 本章只用了足够解释分支指针的那点对象模型。
- 远程跟踪分支
- 一个你不能移动的本地指针,记录一个远程分支在你上一次网络联络时所处的位置。
- 适用处: 只靠网络操作更新;不是可编辑的本地分支。
另外 5 个概念
- 跟踪分支
- 一个与上游远程分支接通的本地分支,使 pull 和 push 知道往哪里去。
- 适用处: 由从远程跟踪分支检出创建,或以后用 -u 设置。
- 变基
- 把一个分支的提交重放到另一个分支之上,得到线性的历史,而不是合并提交。
- 适用处: 最终的快照同合并相同;不同的只是历史。
- 变基的风险
- 重写共享的提交,逼得协作者重新合并,并造成历史重复。
- 适用处: 对从未离开你自己电脑的提交无害。
- patch-id
- 只针对一个提交所引入的改动的校验和,这样 Git 能发现重复的补丁。
- 适用处: 只有两个补丁几乎完全相同时才匹配。
- 变基与合并
- 两种看法:历史作为事实性的记录,对历史作为可读的故事。
- 适用处: 没有通用的胜者;取决于团队和项目。
读后感AI 陪读
每个概念都从上一个流出:这就是这一章行得通的原因
这一章以一个含糊其辞开头——“有些人”称分支是 Git 的杀手级特性——然后花了三十页去挣它自己不肯用自己声音做出的那个主张。它真正的引擎是一句话:分支是一个指向提交的轻量的、可移动的指针。接受这一点,通常让分支显得神秘的一切就消解了。没有复制,没有目录,没有重复的树;只有一个地址。本章随后以令人印象深刻的纪律,从这个基本体往外搭:HEAD 是指向指针的指针,checkout/switch 是移动 HEAD,快进合并是在没有分叉时移动指针,三方合并是 Git 真正要计算点什么的唯一情形,而冲突标记是工具不再装作的时刻。这个递进是本章结构上最好的成就——每个概念都是前一个的后果,而不是一条要硬记的新事实。
在机制之下,本章实际在论证的是经济学的东西。那 41 字节的一行——四十个字符加一个换行,把换行也算上几乎是炫耀——是修辞上的致命一击,作者也知道。廉价没有被呈现为一个性能数据,而是一个行为主张:当分支不花任何代价时,分支不再是需要理由的仪式,而成了草稿纸。所以本章那个悄悄完美的示例分支 dumbidea,原来是天才之作。藏在那里的教训根本不是关于分支的;而是廉价的分支降低了出错的成本,而这是坏主意会被尝试的唯一原因,也是其中一些原来是好主意的唯一原因。
继续阅读
本章最有意思的张力,是机械上的平等与社会上的分量之间的张力,值得称赞的是,它把这场碰撞演了出来,而不是藏起来。一开头:master 并不特殊,它与任何其他分支完全一样,它的特权纯粹是约定和惯性——“大多数人懒得去改它”。后来,在重命名一节:改这个名字,会破坏集成、服务、辅助工具、构建与发布脚本。两者都是真的。Git 拒绝让 master 特殊;生态系统却照样造出了这份特殊,在 CI 配置、webhook 默认值和十年的教程里。同样的动作在 origin 上重演,又是一个人人都读成架构的默认值。诚实的结论是:Git 的平坦,在数据层是真的,在组织层是虚构的,而这两者之间的缺口,就是大多数真实世界里的痛苦所在。
远程一节是本章在实用上挣到饭碗的地方,它转在两句话上。第一:远程跟踪分支是你不能移动的本地引用;Git 替你移动它们。这一个重新框定,杀死了整个 origin/master 的困惑——它不是你的分支,它是上一通电话的收据。第二,更容易被略过:git branch -vv 不会去联系服务器,它报告的是它缓存的东西。“落后1个”可能是上周二的谎言。把所有分布式系统的错觉,压缩在一个从句里,而本章把它标出来是对的,尽管大多数读者无论如何都会吃苦头才学到。
然后是变基,本章的口气在这里转变,也最能说明问题。它是靠美学来推销变基的——你不会发表一本书的初稿,那为什么要展示你凌乱的工作?——我会在这里反驳。提交历史不是像散文那样读的;它是像法医学那样读的,由凌晨两点在二分查找的人来读。为了优雅而编辑,可能毁掉证据。作者自己那句中立的话,变基之后“看上去所有的工作都是依次发生的,即使它们本来是并行发生的”,作为一个特性被说出来,三段之后才被承认是一场哲学上的争论,这是伦理上的症结:变基不是在整理历史,而是在断言一段没有发生过的历史。尽管如此,本章落得很好。它涵盖了真正微妙的失败模式——对别人已在其上建构的提交作变基,以及随之而来的重复工作的乱局——然后解释 patch-id 如何使 git pull --rebase 在别人强制推送时,能部分地救你。它把合并对变基框定为关于历史是干什么用的两种看法:对发生了什么的记录,对工作该怎样理解的故事。而在照例提出“每个团队不同”之后,它忍不住给出了实际的答案——推送之前在本地变基,决不对已推送的东西变基。这是诚实的。那个对称是修辞性的;那个建议是真的。还要留意,这条禁令靠的是一个关于被朋友和家人鄙视的玩笑来执行的,因为 Git 允许强制推送,只有羞耻才能阻止它——这是本章唯一的承认:版本控制里最硬的规则,是社会性的,而不是技术性的。
我想对读者说的是:抓住指针。这一章里几乎每条令人困惑的错误信息,只要你问是哪个指针动了、它是不是该由你来动,就变得显而易见。信任那些拒绝——当 Git 因为改动不干净而不让你切换时,它是在保护你还没学会珍视的工作。把冲突标记看作不是失败,而是工具对自己局限诚实的唯一地方:它能合并文本,却永远合并不了含义。鲁莽而频繁地分支,因为这正是设计想买到的行为。并且只在历史仍是私人的时候重写它,心里想着凌晨两点的调试者,而不是一个想象中欣赏你文笔的读者。
在 FeedLitra 里读第 3 章,每段旁边都有这些卡片。
开始读书第 4 章:服务器上的 Git
托管方案和自托管,在成本和控制上怎样比较?
第 4 章是怎样展开的AI 辅助
开场:一个逼出共享会合点的问题。 本章不是从服务器软件开始的;它是从一个论证开始的,论证为什么根本需要一台服务器。作者推论,协作不应当由开发者直接往彼此的副本里推送构成。他靠否定的方式确立这一点,指出什么会出问题:队友的个人机器大部分时间是离线的,所以依赖它的人,每当那台笔记本合上就失去访问;而往一个有人正在使用的仓库里推送,会把进行中的工作搅乱。从这两个失败,引出本章的组织性对象——一个可靠的中间仓库,通常是一个“裸”仓库,放在服务器上。此后的一切,都是对“数据怎样往返这个对象”这一问题的回答。
展开:四种协议,按要紧的维度比较。 目的地定下之后,本章转向传输,列出本地协议、HTTP(分哑 HTTP 和智能 HTTP 两种)、SSH 和原生的 Git 协议。这个比较不是中立的编目;它是沿着方便和安全这两条轴的排序。智能 HTTP 挣得了“使用最广泛的选项”这一说法,理由是一个单一的 URL 同时涵盖匿名读取和认证推送——在 GitHub 这样的服务上,你浏览项目的地址,就是你克隆和推送用的地址——带着一个提醒:HTTPS 配置起来可能有些麻烦。朴素的 Git 协议随后直接放在这番赞美的对面,成为本章论证最尖锐的时刻:它是最快的选项,恰恰因为它跳过了所有密码学和所有认证,而作者用一个实际的攻击演示其代价。一个控制着通往主机路径上路由器的对手,可以改写你所接收的东西,把恶意代码塞进克隆,而这些代码在你编译或运行项目的那一刻就运行起来。通过 https:// 抓取,除非攻击者能伪造证书,就去掉了这种暴露。换句话说,速度是用本可以防止这次攻击的那些保护换来的。
转折:从比较到一台最小可用的服务器,再到逐步锁死。 这里本章把口气从概览转到演示,而它的第一个演示是故意落空的高潮。作者用 scp 把一个裸仓库复制到 /srv/git,并指出,字面上需要的就是这些:任何能 SSH 进来、对那个目录有写权限的用户,都可以立刻克隆和推送。然后他把它扩展到一个小团队,办法是收集约翰、乔西和杰西卡的公钥,把它们追加到一个共享的 git 账户的 ~/.ssh/authorized_keys,初始化一个裸仓库,然后推送——一套完整的多开发者设置,只用密钥和裸仓库就拼起来了。但本章真正的运动,是随后的纠正,而此时很容易把设置读成已经完成。那些用户现在拥有对服务器端目录的完整 shell 访问权,没有什么能阻止他们中的某一个改动或删除 Git 的内部文件,破坏仓库。所以访问被分两层收窄:把 git-shell 设为登录 shell,使推送和抓取仍然能工作,而交互式登录以 fatal: Interactive git shell is not enabled 告终;又因为光这样仍然让 SSH 端口转发可以用来到达别的主机,就把 no-port-forwarding 和配套选项放在每个密钥前面。Git 命令得以幸存;隧道和 shell 则不能。
拓宽:另外三种服务模式,各有各的权衡。 本章随后回到协议列表,把剩下的选项当作部署搭起来。Git 守护进程把认证完全放弃,换取原始的速度和零密钥管理,这使它适合放在防火墙之后,服务许多只读的消费者——否则每个都需要一个 SSH 密钥的构建和 CI 服务器——而在防火墙之外,只适合你乐于使其公开于世的项目;仓库必须一个一个地选择性开放。智能 HTTP 被表明就是 Git 已经自带的 git-http-backend CGI 脚本,而本章小心地说明安全实际住在哪里:这个脚本自己不做任何认证,所以网络服务器强制执行什么,“就成了”Git 的访问策略。一个只对 git-receive-pack 要求有效用户、让抓取保持开放的 Apache 配置块,就是一个协议、一个 URL 怎样同时提供匿名读取和认证写入的办法——而作者建议,即使在这最简单的例子之上,也要叠加 SSL。从这里,本章沿着栈往上走到界面,从轻量的 GitWeb 查看器,到完整的 GitLab 安装,再到第三方托管。
收束:一个政策问题,而不是技术问题。 从两个方向把服务器搭起来之后,本章以权衡自托管与托管服务收尾——一边是控制和自己防火墙后面的隔离,一边是低成本的设置和维护——并拒绝裁决,把这个决定放在机构的政策里。这个结尾同本章一贯的方法一致:每个选项都带着它的价格被呈现,读者手里拿着的是比较,而不是裁决。
关键论点,以及支撑它们的原文
协作应当经过一个可靠的共享仓库,而不是人们直接往彼此的副本里推送。
作者从什么会出问题推论:队友的个人机器大部分时间是离线的,所以依赖它的人就失去访问,而往某人的工作仓库里推送,会把他们进行中的工作搅乱。这就是为什么在服务器上设一个裸仓库,作为本章其余部分的会合点。
智能 HTTP 已成为搬运 Git 数据使用最广泛的办法,因为单一的 URL 同时涵盖匿名读取和认证推送。
不是为公开克隆和为写入分别宣布不同的 URL,而是一个地址两样都做——在 GitHub 这样的服务上,你浏览项目的 URL 也是你克隆和推送用的。作者的提醒是,在一些服务器上,HTTPS 配置起来可能更麻烦。
朴素的 Git 协议是最快的选项,但没有认证和加密,使它真的危险。
与刚被赞美的 HTTPS 选项相对,作者的实际案例展示:一个控制着通往 example.com 路径上路由器的攻击者,往 git:// 克隆里注入恶意代码,它在项目被编译或运行时执行——速度恰恰来自跳过了本可以防止这一点的密码学。
另外 3 个论点
一台真正有用的 Git 服务器,除了 SSH 账户和一个放在共享路径里的裸仓库,什么也不需要。
作者用 scp 把一个裸仓库复制到 /srv/git,并指出字面上需要的就是这些:任何能 SSH 进来、对那个目录有写权限的用户,都可以立刻从它克隆,并往它推送。
对一个小团队,一个持有每个人公钥的共享“git”账户,就够做一台可读写的服务器了。
作者收集约翰、乔西和杰西卡的公钥,把它们追加到 git 账户的 ~/.ssh/authorized_keys,然后初始化一个裸仓库并往里推送——一套完整的多开发者设置,由密钥加裸仓库拼起来。
Git 守护进程把认证放弃,换取原始的速度和零密钥管理,所以每个仓库都必须有意地开放。
作者论证,放在防火墙后面,它适合许多只读的消费者,比如构建或持续集成服务器,否则每个都需要一个 SSH 密钥;在防火墙之外,它只适合你乐于使其公开于世的项目,而仓库必须逐个选择加入。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 git:// 的克隆能运行攻击者的代码
Git 协议不带 TLS 或任何其他密码学,也不带认证,所以谁控制了网络路径上的一台机器,谁就能改写你所接收的东西。作者表明,路由器层面的攻击者可以把恶意代码塞进克隆的项目,而它在你构建或运行的那一刻就运行起来;通过 https:// 抓取,除非攻击者能伪造证书,就去掉了这一点。
机制 2
分两层锁死 SSH
把 git-shell 设为登录 shell,让共享账户仍能推送和抓取,而交互式登录以“fatal: Interactive git shell is not enabled”的信息告终。这仍然让 SSH 端口转发可以用来到达别的主机,所以作者在每个密钥前面加上 no-port-forwarding 和类似的选项——Git 命令继续工作,隧道和 shell 则不能。
机制 3
智能 HTTP 把认证交给网络服务器
智能 HTTP 不过是 Git 已经自带的 git-http-backend CGI 脚本,而那个脚本自己不做任何认证;网络服务器强制执行什么,就成了 Git 的访问策略。所以一条 Apache 规则可以只对 git-receive-pack 要求有效用户,而让抓取保持开放,这就是一个协议、一个 URL 怎样同时提供匿名读取加认证写入的办法。
常见误读
一旦每个人的密钥都放进了共享“git”账户的 authorized_keys,服务器就这样是安全的了。
这些用户拥有对服务器端目录的完整 shell 访问权,没有什么能阻止某人改动或删除 Git 的内部文件、破坏仓库——这就是为什么作者有意收窄访问,先是把 git-shell 设为登录 shell,然后从每个密钥上去掉端口转发,因为光有 git-shell 仍留着隧道。
既然智能 HTTP 能对推送要求登录而让克隆保持开放,认证就是 Git 自己在做。
git-http-backend 这个 CGI 根本不实现任何认证;保护完全来自网络服务器的配置,比如一个对 git-receive-pack 要求有效用户的 Apache 配置块,而作者仍建议在这最简单的例子之上加上 SSL。
第 4 章的核心概念
- 裸仓库
- 一个只含有 .git 数据、没有检出的工作目录的 Git 仓库,纯粹用作协作点。 一个服务器端的、没有工作目录的仓库,用 git init --bare 创建,只用来接收推送。
- 适用处: 适合服务器;你不能在里面直接编辑文件。 你不能在里面编辑文件;必须有人登录进去,为每个项目建一个。
- Git 传输协议
- Git 在仓库之间搬数据的四种方式:本地、HTTP、SSH 和 Git,各有不同的安全与设置上的权衡。 一个通过 git:// 协议、在 9418 端口上完全不带认证地暴露仓库的服务。 通过 git-http-backend CGI 在 HTTP 上提供 Git,让网络服务器提供任何认证。
- 适用处: 选择取决于认证需求、匿名性、速度和防火墙的约束。 只用于你接受其在网络内公开的数据;每个仓库都必须用 git-daemon-export-ok 选择加入。 这个 CGI 自己不实现任何认证;对非常老的客户端,它退回到哑 HTTP 的行为。
- SSH 公钥认证
- 用户生成一对密钥,把他们的 .pub 密钥交给服务器,这样服务器无需密码就能认出他们。
- 适用处: 不改变提交的作者数据;要求在服务器上有 shell 访问权。
- git-shell
- Git 自带的一个受限的登录 shell,允许 Git 传输命令,但阻止交互式的 shell 访问。
- 适用处: 它本身并不阻止 SSH 端口转发;那需要 authorized_keys 的选项。
- GitLab 的命名空间
- 对每个用户或每个小组的项目的分组,同时构成项目 URL 的路径。
- 适用处: 每个项目恰好属于一个命名空间,而销毁一个用户,会移除他命名空间里的一切。
- 合并请求
- GitLab 里把一个分支或复刻合并进另一个的提议,带着逐行的讨论。
- 适用处: 没有推送权限的贡献者必须先复刻;决定什么落地的,仍是所有者。
另外 2 个概念
- 自托管的 Git 服务器
- 一台你自己运行、在你自己网络里的 Git 服务器,所以你完全控制它,却必须维护它。
- 适用处: 只有当控制或防火墙内隔离的价值,超过持续的设置和维护时间时才值得。
- 托管的 Git 服务器
- 一项替你运行 Git 服务器的第三方服务;容易采用,但你的代码活在别人的机器上。
- 适用处: 在机构政策禁止代码存在外部的地方不可用。
读后感AI 陪读
Git 没有权限系统:文件系统的访问决定一切
这一章以一个平凡得把自己的利害都藏起来了的问题开头:两个人一起干活的时候,代码放在哪里?它的回答是泄气式的,而这个泄气就是本章真正的论证。Git 服务器就是一个裸仓库——你自己的 .git 目录,脱掉了衣服——放在几个 SSH 账户都能写的目录里。就这样。接下来的一切,从 git-shell 到 GitLab,都是叠在一个在最初几页结束时就已经完整的设计之上的方便。
使这一章值得细读的是,它从不完全承认自己有多少结构,是从一个早期的动作而来:Git 把授权完全委托给文件系统。推送权限就是写权限;Git 自己没有权限模型。这个选择买来了极大的简单,然后在本章余下的部分里收利息。协议的比较,其实是对一层层膜的比较——本地文件系统的访问,意味着协作者可以翻看彼此的内部,而围着“远程”打的引号,比周围的文字做的活更多。共享的“git”账户这个把戏之所以行得通,只因为传输的身份和作者的身份是分开的,这也意味着服务器无法告诉你是谁推送的。git-shell 关上了 shell,却让端口转发开着。智能 HTTP 把认证推给网络服务器,这就是为什么 Apache 配置必须靠字符串匹配一个查询参数,来推断是读还是写——本章最脆弱的一行,也是没有授权层可问的直接后果。每一次锁死,都暴露下一处漏洞,因为那把锁从来不是 Git 的一部分。
继续阅读
本章的口气,也比它那套“优点与缺点”的脚手架所假装的,要有主见得多。它直说,相对智能 HTTP,其他协议的优势很少,这悄悄把一份概览变成了一项建议。而 git:// 一节显然是分层的:优点里写“可用的最快协议”,几行之后是一个任意代码执行的警告,仿佛一个更偏执的十年,在一个较早的十年的页边上写了字。同样的沉积也显现在 GitWeb 那几页——Leopard、Ruby 1.8.6、WEBrick——一个鬼魂在演示一个工具。不要把这些当作错误来读,而要当作断代的证据:你能看出文本哪里被修订过,哪里原封未动。
这条弧线最有意思的转折,是接近结尾处的口气转变。前半部分用文件权限和 shell 来推理;GitLab 用命名空间、封锁对销毁用户、机构记忆来推理。而复刻加合并请求,在社会层面解决了 shell 和密钥花二十页都没能在技术层面解决的事:别再试图把不可信的人挡在外面,而是确保他们做的任何事,没有一声“同意”就不会落地。这正是本章开头同样的洞见——开篇那条反对点对点推送的警告,从来不是关于机制,机制运转良好,而是关于人把彼此弄糊涂。本章结束在它开始的地方,学到了问题一直是社会性的。
所以我给读者的建议是:不要把这一章读成四种协议和五种服务器选项的菜单。把它读成一次演示:Git 激进的简单是真的,而它是把难处挪了位置,而不是消除了。亲手搭一次两账户的裸仓库,这样你知道需要多么少,也知道它让你多么暴露。然后选你的那一层——gitolite、GitLab、托管服务——不按功能清单,而按本章的哪些漏洞你承受不起:审计追踪,细粒度访问,或维护负担本身。结尾自托管对托管的选择,被框成机构的政策,这是诚实的;技术上的案子,早就在几页之前定了。剩下的是你愿意信任谁,以及信任到什么程度。
在 FeedLitra 里读第 4 章,每段旁边都有这些卡片。
开始读书第 5 章:分布式 Git
像 issue54..origin/master 这样的提交范围,怎样让你确切看到你还欠服务器什么?
第 5 章是怎样展开的AI 辅助
本章开头点明了一个结构性事实,其余的一切都由它而来:因为每个克隆都是完整的仓库,每个开发者可以同时既是节点,又是枢纽。作者拿它去对比集中式工具——那里开发者只是围着唯一权威枢纽转的节点——并借这个对比为后文立据:接下来要做的是一次对各种可行安排的巡礼,而不是强推某一种模型。三种安排按规模递增排开:集中式工作流,人人推送到同一个共享仓库;集成管理员工作流,贡献者发布各自的公开副本,由维护者从中拉取;以及适用于超大项目的独裁者与副手安排。这次巡礼的要点是:这些都是选项,项目甚至可以混着用。本章的组织思路已经藏在这里了——贡献和集成是两件分开的事,各有各的节奏。集成管理员循环把它说得具体:维护者推送到规范仓库,贡献者克隆它,推送到自己的公开副本,再请求拉取;维护者把那个副本加为远程,测试、合并、发布。贡献者不必等到被接受,就能继续干活。
开发:灵活变成约束
接着,本章从各种安排转向贡献者的座位,在这个转换里,开头的灵活性变成了一组约束。贡献没有唯一正确的方式;怎么做才对,取决于有多少活跃的贡献者、项目选了哪种工作流,以及你能不能推送。第一个变量背后的道理值得留意:在一个有数千名开发者、每天数百个提交的项目里,你提交的工作可能在等待期间,被陆续落地的合并变得过时或被搞坏——所以提交习惯必须适应项目的规模。提交权限同样坚决地决定路线,比如一个只被允许推送主题分支、不能推 master 的开发者。在演练之前,先插入提交准则:清理空白,把改动按逻辑拆开,一行简短的祈使语气摘要,后面接上说明。然后是最简单的情形——私有的小团队或受管理的团队——被当作一个“抓取—合并—推送”的循环来演练。有启发的时刻是第二次推送被拒。推送只有在能把远程指针往前挪时才会成功;一旦别人的提交已经躺在服务器上、而你本地没有,你的历史就不再是一条笔直的延续,推送会因非快进而被拒绝。把他们的工作抓取下来并合并之后,远程的顶端就能从你的顶端到达,推送随之通过。
转折:走出私有团队,进入公开项目
本章的铰链,是从共用一个仓库的团队,转向你完全没有写权限的项目。这里的建议是:把你的工作作为主题分支推送到你自己的复刻上,而不是合并进你自己的 master,给出的理由是务实的,而不是风格上的:如果工作被拒绝,或只被部分拣选,什么都不用倒回——你直接放弃那个分支就行。演练中,贡献者把复刻加为远程并推送功能分支;当维护者要求变基时,分支在 origin/master 之上重建并强制推送,这样做安全,恰恰因为它是一个无人在其上继续构建的孤立主题分支。第二种公开风格是通过邮件贡献,它带出本章最尖锐的工具论证:用 format-patch 构造邮寄的贡献,而不是普通的 diff。format-patch 为每个提交输出一封 mbox 格式的邮件,带编号,带 From、Date 和 Subject 头,这样接收方就可以用 git am 套用整个系列,并保留原来的作者身份和提交信息。作者自己套用补丁后的结果展示了回报——Author 一行仍是贡献者,Commit 一行记录的是套用补丁的人,这些信息是光秃秃的 diff 根本带不了的。
桌子另一边的开发:维护者
本章的后半段换的是座位,而不是话题,这种对称是有意的:贡献者刚才做的一切,现在都作为别人的收件箱到来。补丁通过 git apply 或 git am 落进专门的主题分支,失败的情形得到了认真的对待。如果一份邮寄的补丁发生冲突,三方选项并不是万能的救星——它的做法是重建补丁所依据的基础树,所以只有当这份补丁所基于的那个提交确实存在于你的仓库里时才管用。没有它,就没有什么可重建的,冲突只能另想办法解决;apply 的检查模式事先告诉你处境如何,并以非零状态退出,好让脚本据此分支。直接从贡献者的远程拉取,被作为更轻量的替代方案提出,接着出现的是评审问题。一旦主题分支和 master 各自都向前走了,拿它们的两个顶端去比较,就会把对方的改动混进画面——master 加的一行,读起来像是你的分支删了它。三点语法修正了这一点:它是对两段历史分叉处的那个提交做差异,只分离出主题分支自己贡献的东西。
收尾:集成例程,然后发布
本章的收尾,先把集成问题放大到规模上,然后从中走出来。维护者需要的例程,随着利害的大小而增长:作者从最简单的情形开始——把主题工作直接合并进 master——指出对较大的项目这会变得有风险,升级到带一个试用分支的两阶段安排,然后在 Git 项目本身里展示极端的情形:它维护着 master、next、seen 和 maint,并随着主题变得稳定把它们往上提升。他明确指出,这个四分支例程是 Git 专用的,不是给别人套的模板,并提到变基和拣选作为合并的替代方案。rerere 让更重的例程变得负担得起:当同样的长期主题被反复合并,同样的冲突会一再出现,所以 Git 记下你是怎么解决的,并在以后的冲突匹配时重放那个解决——这恰恰把反复的试合并变成了一个可持续的习惯。最后一个乐章离开集成,转向发布:用签名标签为一个版本背书,用 git describe 起人读得懂的构建名,用 git archive 产出压缩包,用 shortlog 生成变更日志。于是本章结束在一个维护周期结束的地方——不是代码被接受,而是一次发布,被交还给本章开头那个同样分布式的网络。
关键论点,以及支撑它们的原文
因为每个克隆都是完整的仓库,协作变成了在几种可行安排之间做选择,而不是被强推某一种模型。
作者把两种环境对比:在集中式工具之下,每个开发者只是围着一个枢纽转的节点,而有了 Git,每个开发者可以同时是节点和枢纽——这正是本章能把集中式、集成管理员、独裁者与副手这几种配置当作选项摆出来的原因,项目甚至可以混着用。
把贡献这个动作和集成这个动作分开,让贡献者和维护者各按各的节奏走。
顺着这种灵活性,作者把集成管理员循环讲了出来:维护者推送到规范仓库,贡献者克隆它,推送到自己的公开副本,并请求拉取;维护者把那个副本加为远程,测试、合并、发布。贡献者不必等到被接受,就能继续干活。
贡献没有唯一正确的方式——取决于有多少活跃的贡献者、项目选了什么工作流,以及你能不能推送。
把前面的灵活性变成约束,作者推理说,在一个有数千名开发者、每天数百个提交的项目里,你提交的工作可能在等待期间被陆续落地的合并变得过时或被搞坏,所以提交习惯必须适应项目的规模。那位只被允许推送主题分支、不能推 master 的开发者的例子表明,提交权限同样决定了路线。
另外 3 个论点
没有写权限时,把你的工作作为主题分支推送到你自己的复刻上,而不是合并进你自己的 master。
作者给的理由是务实的:如果工作被拒绝,或只被拣选,什么都不用倒回——你直接放弃那个分支。演练中,贡献者把自己的复刻加为远程并把功能分支推送过去;当维护者要求变基时,分支在 origin/master 之上重建并强制推送,这样做安全,恰恰因为它是一个孤立的主题分支。
邮寄贡献时,用 format-patch 来生成,而不是普通的 diff。
format-patch 为每个提交输出一封 mbox 格式的邮件,带编号,带 From、Date 和 Subject 头,这样接收方就可以用 git am 套用整个系列,并保留原来的作者身份和提交信息。以作者自己的情形为例,套用这样一个补丁产生的提交,Author 仍是贡献者,Commit 一行记录的是套用补丁的人——这些信息是光秃秃的 diff 带不了的。
维护者需要的集成例程,随着项目里利害的大小而增长。
作者从最简单的情形开始——把主题工作直接合并进 master——指出对较大的项目这会变得有风险,并升级到一个两阶段的安排,然后才展示极端的情形:Git 项目本身维护着 master、next、seen 和 maint,并随着主题变得稳定把它们往上提升。他特别指出,这个四分支例程是 Git 专用的,不是给所有人套的模板。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么第二次推送被拒
推送只有在能把远程指针往前挪时才会成功。一旦别人的提交已经躺在服务器上、而你本地没有,你的历史就不再是一条笔直的延续,于是推送因非快进而被拒;把他们的工作抓取下来并合并之后,远程的顶端就能从你的顶端到达,推送随之通过。
机制 2
拿共同祖先做比较
主题分支和 master 各自向前走了,所以拿它们的两个顶端比较,就把对方的改动混进了画面——master 加的一行看起来像是你的分支删了它。对两段历史分叉处的那个提交做差异,就只分离出主题分支自己贡献的东西。
机制 3
重放已记录的冲突修复
当你反复合并同样的长期主题,同样的冲突会一再出现。Git 可以存下你是怎么解决的,并在以后的冲突与已记录的匹配时,自己套用那个解决方案——这正是让多分支集成例程里反复的试合并负担得起的原因。
常见误读
既然 Git 是分布式的,团队其实不必就工作流达成一致——每个人想怎么干就怎么干,自然就会理顺。
这种灵活是组织层面的,不是自动的:团队仍然得就一种安排、就谁能往哪里推送达成一致;而作者把正确的贡献方式说成取决于贡献者人数、项目选定的工作流,以及你自己的提交权限——比如一个没有 master 推送权限的开发者,就得把工作经由功能分支和集成者来走。
如果一份邮寄的补丁发生冲突,给 git am 加上三方选项,怎么都能把它弄进去。
三方尝试的做法是重建补丁所依据的基础树,所以只有当这份补丁所基于的那个提交确实存在于你的仓库里才管用;否则就没有什么可重建的,补丁仍得用别的办法解决。先用 apply 的检查模式看一看,就知道自己处境如何,它还会以非零状态退出,好让脚本据此行事。
第 5 章的核心概念
- 分布式版本控制
- 每个克隆都是完整的仓库,所以代码可以在许多对等方之间流动,而不只经过一台服务器。
- 适用处: 灵活是社会性的,不是自动的:团队仍然得就一种工作流、谁能往哪里推送达成一致。
- 集中式工作流
- 一个所有人都往里推送的枢纽仓库;后推送的人必须先合并。
- 适用处: 小团队、大团队都行,但要求人人有推送权限,并在客户端做合并。
- 集成管理员工作流
- 每个开发者拥有一个公开的克隆;维护者拉取、测试、合并,并推送到规范仓库。
- 适用处: 需要一个公认的规范仓库,和一个愿意评审的维护者;贡献者同时继续干活。
- 独裁者与副手工作流
- 副手整合各个子系统,仁慈的独裁者把它们合并起来,并推送一个所有人都变基到其上的参照分支。
- 适用处: 只在巨大或层级森严的项目里才划算,比如 Linux 内核。
- 提交准则
- 让补丁能被评审的习惯:没有空白错误,每个提交一个逻辑上的改动集,约 50 个字符的祈使语气摘要,外加详细的正文。
- 适用处: 是约定,不是强制;书里的示例为了简洁,自己就没遵守。
- 快进合并
- 当你的分支严格落后时,Git 只是把指针往前挪,不产生合并提交。
- 适用处: 当远程有你所没有的提交时,推送恰恰会因非快进而被拒绝。
另外 10 个概念
- 引用规格
- 一对“源:目标”,告诉 push 本地哪个分支更新远程哪个分支。
- 适用处: 只在本地和远程的分支名不同,或你想要非默认映射时才需要。
- 主题分支
- 一个短命的分支,装着一个主题的工作,这样它可以被单独改写或丢弃。
- 适用处: 每个新主题都从 origin/master 开始,而不是从之前推送过的主题分支开始。
- 拉取请求
- 一份请求维护者合并你复刻里的工作的通知,通过网站,或通过 git request-pull 的输出。
- 适用处: request-pull 只产生一份摘要;你仍得自己把它发邮件。
- format-patch 邮件工作流
- 把每个提交变成一封 mbox 邮件,可以发到邮件列表,并带着完整的提交信息重新套用。
- 适用处: 只有接收方用 git am 套用才行,用普通的 diff/patch 不行。
- git am
- 套用一个 format-patch 邮件的邮箱,创建带着原作者和原信息的提交。
- 适用处: 需要 mbox 格式的输入;旧式的 diff 补丁要改用 git apply。
- 压缩合并
- 把一个分支的工作压成暂存区里的一个改动集,不记录合并提交。
- 适用处: 得到的提交只有一个父提交,所以合并关系没有被记录下来。
- 三方合并
- 给 git am 加上 -3 标志,让 Git 重建一棵基础树并合并,而不是干脆地拒绝一份有冲突的补丁。
- 适用处: 只有当这份补丁所基于的提交存在于你的仓库里才管用。
- 三点差异
- git diff master...contrib 只显示你的分支自与 master 的共同祖先以来新增了什么。
- 适用处: 只在历史已经分叉时才需要;那时普通的 diff 会误导人。
- Rerere
- “重用已记录的解决方案”:Git 记住你是怎么解决冲突的,下次自动重放那个修复。
- 适用处: 只有以后的冲突和已记录的完全一样时才适用。
- 用 describe 得到的构建号
- git describe 把一个提交命名为:最近的标签 + 其后的提交数 + 简短的 SHA-1。
- 适用处: 默认要求附注标签;缩写的 SHA-1 将来未必仍然有效。
读后感AI 陪读
最精明的一招:先许诺什么都不变,再展示彻底的力量
这一章的弧线,是从可能性缓缓下降到政治。它以一切所系的那句话开头——每个开发者都“可能”既是节点又是枢纽——而我坚持那个副词:Git 化解了通往任何拓扑的技术障碍,然后拒绝选择一种。填满这个真空的是社会性的东西。所以开头概览的三种工作流(集中式、集成管理员、独裁者与副手),不是一架精密程度的梯子,而是信任安排的一份目录,而最远端的内核例子泄了底:独裁者工作流不是靠探索 Git 的设计空间发现的;Git 是为适应一个早已存在的形状而造的。因果关系是倒着走的,不同于本章的呈现。
然后是整篇里最精明的修辞动作:描述了激进的去中心化之后,作者立刻许诺你可以什么都不改——一个仓库,人人都有推送权,Git 不会让你们互相覆盖。迁移指南总是先卖安全,再卖力量。接下来小团队的演练兑现了这个许诺,因为它真正的有效载荷,是这样一刻:两个“没碰同一个文件”的开发者,推送仍然被拒绝。这是本章最尖锐的教学节拍,也是整件事的概念铰链:Git 的冲突单位是历史,而不是文件。SVN 合并文件;Git 合并时间线。引用规格、非快进,以及分支名纯属本地意见(杰西卡的 featureB 落成了 featureBee,Git 耸耸肩)的一切古怪,都出自这一次的位置挪动。
继续阅读
中间的贡献部分,是书最有人情味、也最悄悄对自己不安的地方。它点出一种真实的内疚——一个周末搅在一起的五个问题,在周一作为一个提交交上去——然后提供的不是纪律,而是工具:暂存区让你事后假装自己有纪律。给出的理由是诚实而有力的(不管你做一个提交还是五个,顶端的快照都一样;只有读者在乎),而祈使语气的规则,比它所述的语法更好:一个提交是对树的一条指令,而不是对过去的一份报告。但留意那个动词——你“精心制作”历史。那是你编辑的叙事,不是你累积的日志。那是真正的力量,也隐隐令人不安,而本章无法阻止它的两个声音互相摩擦:在论证历史是协作者实际消费的东西之后,它又对合并顺序挥挥手,说“只有历史会不同”。“照我们说的做,别照我们做的做”的这个坦白,在四行里重复了两次,把这种张力转成了魅力,同时承认这些准则的代价大到连作者们自己也跳过。
主题分支和两种公开贡献的风格,是我最想逼读者的地方,因为所说的理由是整洁,实际的理由是被拒绝。你保持 master 干净,是因为你的工作多半不会原样落地——会被拣选、重写,或丢弃——而孤岛(这回是美德,不是蔑称)正是使工作可以丢弃而无附带损害的东西。本着同样的精神,变基到 origin/master 并自己吃下冲突,不是卫生而是礼貌:一条不成文的规则:解决冲突是贡献者的劳动。而复刻加拉取请求这条路,是分布式乌托邦撞上组织结构图的地方——杰西卡不能往 master 推送,只有集成者可以。这个权限模型哪儿也不在 Git 的架构里;是托管方把它装上去,恰恰重建了 Git 废除过的把关。去中心化原来是基底,不是结果。
邮件补丁那一半,同时朝两个方向变老。糟糕的是,一个明文的密码躺在一个打印出来的点文件里,正下方是 sslverify = false——一部经典文本里真正的瑕疵。漂亮的是格式:差异上方有一块页边,人可以在那里交谈,机器有意忽略;以及 format-patch 携带、而普通 diff 丢掉的作者身份。这个区别——一项记得是谁做的改动——是贡献者各节悄悄的寓意。
维护者的收尾(am 失败,直接从贡献者的远程拉取,三点差异,合并对两阶段对 Git 项目自己的四分支级联,rerere,签名标签,describe,归档,shortlog)读起来像工具箱,实则是一个论证:在规模上,集成成了一项“工作”,有自己的分支,自己对过去冲突的记忆,自己的发布仪式和密码学担保。本章结束在它开始的地方,只是倒转了:枢纽不是架构的恩赐,而是需要有人去干的一个角色。
我想对读者说的是:不要把它读成一份要记忆的命令菜单。把它读成这样一章:Git 不再是一个工具,而开始成为对一群人怎样分配信任的描述,每个命令都是某人早已做出的社会决定的下游。把“历史而非文件”这个洞见烂熟于心——它解释了你将来会得到的大部分错误信息。认真对待提交准则,但不要相信有人完全遵守。并抓住对被精心制作的历史的那份不安;那是正确的反应,也是一份人类读得懂的日志的代价。
在 FeedLitra 里读第 5 章,每段旁边都有这些卡片。
开始读书第 6 章:GitHub
维护者这一侧的流程,与贡献者那一侧有何不同?
第 6 章是怎样展开的AI 辅助
本章开头,先把 GitHub 摆在它的位置上:最大的 Git 托管方,也是大多数人头一次以社交的方式、而不是作为本地工具遇见 Git 的地方。本章的切入点刻意平淡:设置账号、验证邮箱、关联 SSH 密钥、添加头像、登记多个邮箱地址、开启双重认证。顺序很重要——它确立了 GitHub 的协作模型建立在身份之上。一旦你带着验证过的密钥和地址存在于这项服务上,本章就可以进入让这个网站对陌生人也管用的机制:复刻加拉取请求。复刻把整个项目复制到你自己的命名空间,你完全掌控那里,所以你在那里提交和推送,而不是推到原仓库。贡献则以拉取请求的形式送回去,由维护者决定合并与否。这种所有权的划分是本章的核心洞见:维护者在接受外部工作之前,从不必发放推送权限,因为对原仓库的写权限始终锁着,只有一份请求越过边界。
开发阶段具体地走一遍流程——复刻、创建主题分支、推送、开拉取请求——然后着重讲评审者回应时会发生什么。示例刻意很小:托尼把闪烁延迟从 1000 毫秒改成 3000 毫秒的一行改动。反馈到来时,办法是往同一个主题分支上推送额外的提交,而不是把提议重新作为第二个拉取请求发出来。理由是,持久的记录是评审对话加最终的合并提交,GitHub 的合并按钮有意写下一个指回拉取请求的合并提交,让讨论始终可以到达。改写提交什么也买不到,反而会破坏讨论串所指向的东西。同样的逻辑管着过时的请求:把原仓库加为 upstream 远程,抓取,把 upstream/master 合并进你的主题分支,解决冲突,推送——拉取请求就会自己更新并重新测试。如果你真想变基,所说的条件是把它推送到一个新分支,并另开一个引用旧请求的新拉取请求。用 #num 交叉引用议题,补全了社交层,接着是 GitHub 风格的 Markdown:任务列表、围栏代码、引用、表情符号、拖放图片。
让复刻保持最新,被当作一件杂务,而不是一项功能。本章直截了当地说,GitHub 永远不会替你更新你的副本,展示了每次都写出抓取 URL 有多烦琐,然后配置一个带推送默认值的 upstream 远程,让这套例行动作缩成检出、拉取、推送。随之而来的是一句警告:在这个方便的设置里,Git 不会拦着你往一个内容其实属于上游的 master 分支上提交。这是本章对一种自然的误读最尖锐的纠正——远程只是省了你重打一个 URL;同步仍是手动的,因为托管方从不自己刷新一个复刻。
转折出现在视角从贡献者翻到维护者的时候。现在本章创建仓库、添加协作者,并从桌子的另一边处理收到的拉取请求。这里技术上的回报是拉取请求引用:对示例仓库运行 ls-remote,会暴露出服务器端的引用,比如 refs/pull/1/head 和 refs/pull/1/merge,与普通分支并排而坐。它们默认被跳过,恰恰因为它们住在 refs/heads/ 之外,但给它们加一个引用规格,就让每个拉取请求都作为一个本地只读的跟踪引用出现——不用给每个贡献者添加远程,这去掉了本地评审的大部分烦琐。拉取请求上的拉取请求,以及提及与通知系统,补全了维护者的工具箱。
收尾向外扩展到 GitHub 的扩展点,从约定走向可编程的表面。先是通知邮件的头,然后是编码了项目期望的特殊文件 README 和 CONTRIBUTING;接着是项目管理——选择默认分支、转让所有权——以及带团队和审计日志、用于规模化工作的组织。最后是钩子、服务和 API。本章把区别讲得很清楚:webhook 让 GitHub 在事情发生的一瞬间,把事件载荷 POST 给你托管的服务,这样你不用轮询就能知道有推送;而 API 朝着另一个方向走,让你的代码请求更多细节或写回——比如在议题上发一条评论。一个提供触发,一个提供后续。压轴的示例把两者连了起来:一个 webhook 扫描收到的提交里有没有 Signed-off-by 一行,并通过状态 API 发布成功或失败的状态,它在提交列表和拉取请求上显示为绿色对勾和红色叉。这把你在意的任何策略,变成合并时一个看得见的通行/禁止信号,作者指出它对测试结果尤其有用,免得有坏东西被意外合并。他还说出了局限,纠正了一个诱人的假设:拉取请求的视图只反映最后一个提交的状态,所以绿色对勾并不证明分支里每个提交都没问题——状态是按提交附着的,上下文标签只是区分谁报告了什么。于是本章结束在它开始的地方,重机制而不重魔法:GitHub 提供钩子和 API,而你得到的保证,是你自己建起来的那些。
关键论点,以及支撑它们的原文
有了复刻,维护者在接受外部工作之前,就不必再发放推送权限了。
作者推演了所有权的划分:复刻住在贡献者自己的命名空间里,所以所有推送都发生在那里,只有拉取请求越过边界送回,原仓库上的写权限则丝毫未动。
评审者要求修改时,往现有分支上添加新提交,而不是把提议重新发一遍。
在示例中,托尼把闪烁延迟从 1000 毫秒改成 3000 毫秒的一行改动,得到的回应是往同一个主题分支上推送额外的提交,而不是开第二个拉取请求;作者指出,网站的合并按钮有意写下一个指回拉取请求的合并提交,这样讨论始终可以到达。这个案例是一个很小的单文件改动,而不是大型补丁系列。
顺着这一点,过时的拉取请求应当通过把目标分支合并进你的主题分支来刷新,而不是靠变基加强制推送。
作者展示了具体的例程——把原仓库加为 upstream 远程、抓取、合并 upstream/master、解决冲突、推送到主题分支——之后拉取请求会自己更新并重新检查。如果你真想变基,所说的条件是把它推送到一个新分支,并另开一个引用旧请求的新拉取请求。
另外 3 个论点
复刻是一份快照,所以让它保持最新是所有者自己的杂务。
作者直截了当地说,GitHub 永远不会替你更新你的副本,然后展示了每次都写出抓取 URL 有多烦琐,以及配置一个带推送默认值的 upstream 远程如何把例行动作缩成检出、拉取、推送。附带的警告是:在这个方便的设置里,Git 不会拦着你往一个内容其实属于上游的 master 分支上提交。
一旦你抓取了 GitHub 的拉取引用,在本地评审拉取请求就不那么烦琐了。
对示例仓库运行 ls-remote,会暴露出服务器端的引用,比如 refs/pull/1/head 和 refs/pull/1/merge,与普通分支并排而坐;给它们加一个引用规格,就让每个拉取请求都作为一个本地只读的跟踪引用出现,不用给每个贡献者添加远程。它们默认被跳过,恰恰因为它们住在 refs/heads/ 之外。
提交状态把你在意的任何策略,变成合并时一个看得见的通行/禁止信号。
作者的 webhook 扫描收到的提交里有没有 Signed-off-by 一行,并通过状态 API 发布成功或失败的状态,它在提交列表和拉取请求上显示为绿色对勾和红色叉——他指出,这对测试结果很有用,免得有坏东西被意外合并。所说的局限是,拉取请求的视图只反映最后一个提交的状态。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
复刻如何取代推送权限
复刻把整个项目复制到你自己的命名空间,你完全掌控那里,所以你在那里提交和推送,而不是推到原仓库。改动以拉取请求的形式送回,由所有者决定合并与否,这就是为什么对原仓库的写权限可以保持锁定,而外人仍能贡献。
机制 2
为什么合并上游能让拉取请求保持存活
既然持久的记录是评审对话加最终的合并提交,改写提交什么也买不到,反而会破坏讨论串所指向的东西。把原仓库加为 upstream 远程,把它的目标分支合并进你的主题分支,解决冲突并推送,GitHub 就会自动更新并重新测试同一个拉取请求。
机制 3
钩子通知,API 抓取并行动
webhook 让 GitHub 在事情发生的那一刻,把事件载荷 POST 给你托管的服务,这样你不用轮询就能知道有推送。API 朝着另一个方向走:你的代码请求额外的细节,或者写回,比如在议题上发一条评论,所以一个提供触发,另一个提供后续。
常见误读
一旦我设好了 upstream 远程,我的复刻就会跟踪原项目,自己保持同步。
远程只是省了你重打一个 URL;更新仍是你得自己去跑的手动拉取和推送,因为托管方从不刷新你的副本。作者还指出,这个设置在你直接往一个内容其实来自上游的 master 分支上提交时,不会给出任何警告。
拉取请求上的绿色对勾,意味着其中每个提交都通过了检查。
拉取请求这个界面只报告最后一个提交的状态,所以同一分支里较早的提交,可能带着被摘要藏起来的失败;状态 API 是按提交附着结果的,上下文标签只是区分谁报告了什么。
第 6 章的核心概念
- GitHub 复刻
- 把别人的项目复制到你自己的命名空间里,这样你无需成为协作者就能往里推送。
- 适用处: 当你已经有写权限时就不需要;拉取请求也可以在同一个仓库的两个分支之间开。
- 拉取请求
- 一个基于网页、请求合并你主题分支的请求,带着一份差异,外加一个支持逐行代码评审的讨论串。
- 适用处: 额外推送提交会悄悄地更新它——追加提交不会发送通知。
- GitHub 流程
- 一个八步循环:复刻、主题分支、提交、推送、开拉取请求、讨论、所有者合并或关闭、把 master 同步回来。
- 适用处: 它就是集成管理员工作流,只是用网页工具代替了邮件列表里的邮件。
- 交叉引用
- 在任何评论里写 #num、username#num 或 username/repo#num,链接到另一个议题或拉取请求。
- 适用处: 同一个项目里,议题和拉取请求共用编号,所以 #3 不能同时是两者。
- SSH 公钥认证
- 把你的公钥粘贴进 GitHub 设置,让 SSH 远程能用;给每个密钥命名,以后可以吊销正确的那个。
- 适用处: 克隆公开项目,或用用户名和密码做 https:// 推送时,不需要。
- 双重认证
- 登录时,除了密码,还要求第二个验证码,来自手机应用或短信。
- 适用处: 它防的是密码被盗,不防账号遭遇的其他泄露途径。
另外 10 个概念
- GitHub 风格的 Markdown
- GitHub 扩展的纯文本评论语法,渲染得更丰富,增加了任务列表、围栏代码、引用和表情符号。
- 适用处: 适用于 GitHub 的文本框;拖放上传图片是 GitHub 的功能,不是 Markdown 本身。
- 任务列表
- 议题或拉取请求里的复选框列表,点一下就能勾选,并在列表页上作为进度元数据汇总显示。
- 适用处: 主要用来在合并前跟踪一个分支上的工作。
- 拉取请求引用
- 服务器端 refs/pull/ 之下的伪分支,给每个拉取请求一个 /head 和一个 /merge 引用,不用添加远程就能抓取。
- 适用处: 默认不会抓取,因为它们住在 refs/heads/ 之外;需要添加一个引用规格。
- 协作者
- 你添加到仓库、授予推送(读写)权限的用户。
- 适用处: 权限在仓库设置里按用户授予和撤销。
- CONTRIBUTING 文件
- 一个 GitHub 向每个开拉取请求的人显示的文件,写明你的贡献规则。
- 适用处: 任何文件扩展名都行,但它只在创建拉取请求的时候出现。
- GitHub 组织
- 一个由某个群体拥有的共享账号命名空间,由团队控制仓库的访问。
- 适用处: 只有当存的一切都是开源时才免费。
- 团队
- 组织内部一组用户和仓库的归类,用来设定访问级别。
- 适用处: 访问级别分只读、读写或管理员;用户可以加入任意多个团队。
- 仓库 webhook
- 一个 GitHub 向其 POST 事件载荷的 URL,让你自己的服务能做出反应。
- 适用处: 默认只有推送事件;你得自己托管一个监听端。
- 提交状态 API
- 一个给提交附上通过/失败状态的端点,带一个上下文标签来区分不同的报告者。
- 适用处: 拉取请求只反映最后一个提交的状态。
- 个人访问令牌
- 一种可撤销、范围受限的凭据,在 API 脚本里代替密码使用。
- 适用处: 只显示一次;把速率上限从每小时 60 次提高到 5000 次。
读后感AI 陪读
GitHub 备受赞誉的特性:披着技术外衣的约定
GitHub 这一章,是《Pro Git》里唯一一处书走出自己主题的地方,而它自己也知道——开篇的免责声明说 GitHub“不是 Git 开源项目的直接组成部分”,并承认截图会过时,这两处都是在给出任何一条指令之前先设的缓冲。带着这个框架来读,这一章干净地分成两种材料:一层薄而易朽的、绿色按钮和设置页面,以及底下一层持久的、Git 底层机制和社会约定——后者才是真正在教的东西。弧线从身份(账号、SSH 密钥、邮箱、双重认证)走到贡献(复刻、主题分支、拉取请求、迭代、合并),再到 Markdown 和复刻的维护,然后翻转到维护者的座位(仓库、协作者、拉取请求的处理、拉取引用、提及),最后向外到扩展的表面——README 和 CONTRIBUTING、组织团队和审计日志、钩子、服务和 API。这个顺序其实是范围的拓宽:自己、贡献者、维护者、组织、机器。
我一次又一次看穿的是,GitHub 备受称赞的功能,多半是穿着技术外衣的约定。身份是本地配置里一个未经验证的邮箱字符串,所以你历史的作者,就是你打进去的那个。复刻是给一个曾经意味着分裂的词换了个说法,而它真正的发明不是复制一个仓库,而是把提出的权利和写入的权利解耦——评审取代权限成了那道关。被当作平台功能呈现的拉取请求层,原来不过是住在 refs/pull/ 之下的一些引用,默认的引用规格看不见它们,服务器端的 refs/pull/#/merge 里存着一个在你历史里哪儿都不存在的推测性合并提交——而你那个绿色或红色的 CI 状态描述的,恰恰就是这个提交。又因为拉取请求和议题共用一个编号空间,你可以直接从表面读出数据模型:拉取请求就是订着一个分支的议题。Markdown 沿着同样的模式——会写回自己源码的任务列表,是装成应用的纯文本。
继续阅读
我最尖锐的判断,是关于本章的各种观点,它们是以事实的语气说出来的。“变基除了让历史稍微干净一点,并没有给你多少好处”不是 Git 的真理;那是 GitHub 的文化,而不少严肃的项目恰恰反着干。非快进合并是同一论证的强制形态:每一次点绿色按钮,都会留下一个内容为“某个人批准了这个”的提交,用图谱的噪音买下了出处。我觉得这笔交易说得过去——一个兼作推理档案的仓库,值得付出真实的杂乱——但这个论证有一个书从未说出口的依赖,就是这个档案住在 GitHub 的服务器上,而不在你的克隆里。历史活了下来;证明它合理的那场对话,却不会随着 git clone 一起走。这是本章最安静也最大的遗漏。
还有两件较小的事留在我心里,因为它们显出这个平台的接缝。方便的 upstream/origin 复刻设置,只有在你从不往 master 提交时才行得通——一条社会规则代替了技术上的防护,Git 拒绝保护你。而那封通过加载一张图片把网页对话标为已读的通知邮件,被描述成一种便利;它是一个追踪像素,已读回执就是监视。让 @提及 成为本章最好点子的那种推而不拉的逻辑,也让任何人的注意力,都能靠敲一个字符被认领。
我要对读者说的是:以不同的深度把这一章读两遍。截图扫一眼就行,它们早已不对了。然后慢慢读那些描述机制和规范的句子——邮箱映射、refs 命名空间、共用的编号空间、合并提交的政策,还有“千万别往 master 提交”的警告——因为这些部分,在三次界面改版之后仍会是真的。把所说的偏好当作一个社区的内部风格,如果你的团队想要可检索的出处就采纳它,如果你想要一张可读的图谱就心安理得地丢掉它。并且留一个念头在心里:这里几乎一切,都是你能在朴素的 Git 上别处重建的约定,这既衡量了这个设计真正需要多么少的锁定,也是绿色按钮的舒适值得留意、而不是单纯享受的原因。
在 FeedLitra 里读第 6 章,每段旁边都有这些卡片。
开始读书第 7 章:Git 工具
reflog 只存在于本地,这一点怎样改变一次糟糕的变基之后的恢复?
第 7 章是怎样展开的AI 辅助
本章从一个高级工具包必须先回答的最基本的问题开始——你怎么指向你想要的那个提交?它从缩写的 SHA-1 开始,并用开篇的论证为它们辩护:实践中八到十个字符就够了,并以 Linux 内核作为压力测试的例子(到 2019 年 2 月,有超过 875000 个提交和近七百万个对象,连前十二个字符都没有出现过两个哈希相撞),外加一个思想实验,显示即便荒唐的推送量,也要花上多年,才会让一次意外碰撞的概率达到抛硬币的程度。警告说得明明白白:这个界限针对的是意外,而不是攻击。从这里起,词汇按自然的顺序拓宽——分支名、reflog 简称,然后是用 ^ 和 ~ 表示的相对祖先关系,再是通过双点、^/--not 和三点表示的整个范围。整个开篇的要点是杠杆:一旦你能精确地命名任何提交或一组提交,本章后面的每个工具就有了可以操作的对象。
开发:塑形、停放、签名、搜索
在确立了如何指称提交之后,本章转向如何做出好的提交。交互式暂存(git add -i)和补丁模式,被呈现为提交卫生的手艺:暂存一个块,让文件的其余部分不动,得到的是一个聚焦的改动集,而不是混杂的,这样每个提交都是评审者能跟得上的、单一而自足的改动。同样务实的精神延续到 git stash——save、list、apply、drop、pop、--keep-index、-u、--patch、stash branch——它被介绍为放下做了一半的工作、不必提交就能切换任务的办法,但马上加了限定:重新套用会恢复这些编辑,却把已暂存/未暂存的区别压平,除非你传 --index,而且普通的 apply 会把条目留在栈上。git clean(以 -n 和 -i 作为安全护栏)处理储藏不该碰的东西。然后是两段较短的内容:对标签、提交和合并做 GPG 签名,用于出处要紧的时候;以及搜索,通过跨工作树的 git grep,和跨历史的 git log -S/-G/-L——pickaxe,回答的是“这个字符串是什么时候出现或消失的?”,而不只是“它现在在哪儿?”
转折:重写历史与三棵树
git log -L 追踪一个函数或一段行范围穿越时间,是这里的铰链:本章从阅读历史滑向重写历史。修补(amend)、交互式变基(reword、edit、重排、squash、拆分、drop)和 filter-branch,按破坏性递增的顺序到来,随之而来的是本章核心的伦理界线——重写是一种特权,它在你推送的那一刻结束,因为变基已经到过共享服务器的提交,会把同一个改动的第二份、相互竞争的副本交到协作者手里。重写之所以具有破坏性的机制性原因,也得到了专门的解释:每个提交都存着它父提交的哈希,所以一个提交的身份就是它的整个祖先;编辑或丢掉其中一个,下游的一切都会以新的哈希重建,只有在这次编辑之前被重放的、没动过的提交,才保留了它们旧的身份。
“重置揭密”是本章概念上的中心,它把一个令人畏惧的命令重新框定成一个可预测的命令。Git 持有内容的三份副本——HEAD 所指的提交、索引、工作目录——而重置按固定顺序走过它们:移动 HEAD 所指的分支,让索引与之匹配,让工作目录与之匹配。--soft 在第一步之后停下;--hard 三步全做;指明路径则完全跳过分支的移动。这个顺序化解了常见的恐惧,即以为重置就是那个会毁掉工作的命令。只有 --hard 会碰磁盘上的文件,这是重置唯一可能不可挽回地丢失东西的方式——这就是为什么 reset --soft HEAD~2 加一个新提交,能压缩历史而保留每一处编辑。就连 --hard 的例子,也是通过 reflog 恢复了一个旧文件,不过本章小心地交代了原因:那些内容已经提交过。未提交的内容就真的没了。git checkout 随后被放到重置旁边作为对照——它移动的是 HEAD 本身,而且对工作目录是安全的。
高级合并,然后是调试
接着是高级合并,它建立在一个关于 Git 设计的有意断言上:它有意不聪明,只解决毫不含糊的部分,把其余的交给你。例子是一个分支,有人把每一行的行尾都重写了,并改了一个词——Git 把每一行都读作被修改,并在整个文件上冲突——这引出了随后的忽略空白的策略、通过索引阶段手动重新合并,以及 --conflict 重新检出。然后是舒适的工具:diff3 冲突风格、--ours/--theirs 检出、合并日志、组合差异、-Xours 和 -s ours、子树合并,以及 rerere 来记住你已经做过的解决方案。撤销合并得到一个尖锐的区分:revert -m 添加一个抵消改动的提交,却把合并留在历史里,所以主题分支里的一切仍然可以到达,再试一次合并只会报告“Already up-to-date”——你必须还原那次还原,才能把工作找回来,而 reset --hard 则真正抹掉了合并,代价是重写共享的历史。调试然后按各自回答的问题,把两个工具配成一对:git blame(用 -L 指定行范围,用 -C 追踪复制的代码),用于你知道问题在哪里的时候;而 git bisect,带着它的二分查找和自动化,用于你只知道有东西坏了的时候——演练标出一个坏提交和一个好提交,测试中点,最终落在 b047b02 上。
收尾:子模块、传输与可扩展性
最后一段从单个仓库向外移动。子模块围绕一个事实被引入,这个事实解释了它们的全部摩擦:超级项目只存着子模块的提交 id,而不是它的文件。因此,一次全新的克隆会让那个目录保持空着,直到 init 和 update(或 --recurse-submodules),而拉取外层项目只是抓取新被引用的提交——还需要一个单独的 update 步骤才能把它们检出来。然后本章诚实地走过种种后果:在子模块里工作需要一个检出的分支加上 --merge/--rebase,推送需要 --recurse-submodules,分叉的子模块指针必须手工合并,而重新添加或切换分支有它们自己的陷阱。三个较短的收尾把范围进一步拓宽:git bundle,用来在没有网络时通过“运动鞋网络”搬运历史;git replace,用来把被截断的历史嫁接到它更早的延续上;以及凭据助手系统,带着它的 stdin/stdout 协议。本章结束在一次扩展的行动,而不是总结——在展示了原始的 git-credential-store 和它的纯文本文件之后,它用 Ruby 建了一个小小的只读助手,读取一个共享的凭据文件,并通过 credential.helper 注册自己。这对一章讲工具的内容是个恰当的收尾:用整章的篇幅讲了 Git 提供什么之后,它最后表明,这个工具包在边缘是敞开的。
关键论点,以及支撑它们的原文
缩短的提交哈希足够可靠,可以用作提交的日常名字。
作者在本章开头论证,在大项目里八到十个字符就已经保持唯一,并援引 Linux 内核:到 2019 年 2 月,它有超过 875000 个提交和近七百万个对象,连前十二个字符都没有出现过两个哈希相同。他又加了一个思想实验——如果地球上全部 65 亿人每秒都推送一个内核规模的历史,一次意外碰撞要花大约两年,才会变得与抛硬币一样可能,这个界限涵盖的是意外,而不是蓄意的攻击。
交互式地暂存,或在文件内一块一块地暂存,才让每个提交能是评审者跟得上的、单一而自足的改动。
在展示了如何精确地命名提交之后,作者转向一开始怎样塑造它们:在单个文件的补丁模式里,他只暂存一个块,让文件的其余部分保持未暂存,这样当场提交,得到的是一个聚焦的改动集,而不是混杂的。
储藏是放下你正在做的事、切换任务而不提交做了一半的工作的办法——前提是你知道它会恢复什么。
作者的演示把已修改和已暂存的文件停放起来,让状态保持干净,于是切换分支就变得安全。他马上给这个工具加了限定:重新套用储藏会把编辑放回去,却把已暂存/未暂存的区分压平,除非使用 --index,而且普通的 apply 会让条目一直待在栈上。
另外 3 个论点
重写历史是一种特权,在你推送的那一刻结束。
在展示了 amend 和交互式变基之后,作者划出了界线:变基已经到过中央服务器的提交,会把同一个改动的第二份、相互竞争的版本交到协作者手里,所以已推送的工作应当被视为已经定案。
§7.12 Don’t push your work until you’re happy with it阅读这一节 →
Git 在合并上有意不聪明,只解决毫不含糊的部分,把其余的留给你——这就是为什么长期分叉的分支合并起来很痛苦。
作者用一个分支来说明这笔代价:有人把每一行的行尾当作空白改掉了,并编辑了一个词,Git 把所有行都读作被修改,并在整个文件上冲突,所以读者需要他随后介绍的忽略空白的策略、逐阶段手动重新合并,以及 -Xours 选项。
blame 和 bisect 回答不同的调试问题,所以哪一个有帮助,取决于你已经知道什么。
作者明确地把它们配成一对:标注用于你已经知道问题在哪里的时候——对 Makefile 的一个范围运行 git blame -L,会打印每一行背后的提交、作者和日期——而 bisect 用于你只知道有东西坏了的时候,就像他的演练那样:标出一个坏提交和一个好提交,测试中点,最后落在 b047b02 作为第一个坏提交。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
重置走过三棵树
Git 保存内容的三份副本——HEAD 所指的提交、索引,以及磁盘上的文件。重置按顺序走过它们:先移动分支,再让索引与之匹配,再让文件与之匹配;--soft 在第一步之后停下,--hard 三步全做,而指明路径则完全跳过分支的移动。
机制 2
编辑一个提交会给它的后代改名
每个提交都在自己里面记录着父提交的哈希,所以一个提交的身份取决于它的整个祖先。改动或丢掉一个提交,建在它之上的每个提交都必须以新的哈希重建;只有在这次编辑之前被原封不动地重放的提交,才保留它们旧的身份。
机制 3
超级项目只存一个指针
外层仓库只保存子模块的提交 id,而不是它的文件。所以一次全新的克隆会让子模块的文件夹保持空着,直到它被初始化和更新,而拉取外层项目只会下载新被引用的提交——还需要一个单独的 update 步骤才能把它们真正检出。
常见误读
git reset 是会丢掉你工作的危险命令,所以只要有未提交的改动,就应该避免它。
只有 --hard 形式会碰磁盘上的文件,这是重置能不可挽回地毁掉东西的唯一方式;其他形式只是移动分支和索引,这就是为什么 reset --soft HEAD~2 之后再做一个新提交,能压缩历史而让每一处编辑完好无损。就连在 --hard 之后,作者的例子也是通过 reflog 恢复了一个旧的文件版本——但只是因为那个版本已经提交过;未提交的内容就没了。
还原一次不想要的合并,就把它干净地撤销了,所以以后可以直接再合并同一个分支。
还原会添加一个抵消改动的提交,却把合并本身留在历史里,所以 Git 仍认为主题分支里的一切都是可到达的——作者再次尝试合并,只报告“Already up-to-date.”要把工作找回来,需要先还原那次还原,而用 reset --hard 移动分支指针,则真正抹掉了合并,代价是重写共享的历史。
第 7 章的核心概念
- 简短的 SHA-1
- 哈希的前几个字符代替完整的 40 个字符,只要它们是唯一的。
- 适用处: 至少要四个字符,并且没有其他对象共用这个前缀。
- RefLog 简称
- Git 记录 HEAD 和各分支最近指向过哪里,所以你可以说 HEAD@{5} 或 master@{yesterday}。
- 适用处: 严格是本地的,而且只覆盖最近几个月;新克隆的仓库 reflog 是空的。
- 祖先引用
- ^ 表示一个父提交(用数字选择是哪一个父提交),~ 重复地沿着第一个父提交往上走。
- 适用处: ^2 只对合并提交有意义,因为只有它们有不止一个父提交。
- 双点
- master..experiment 列出从一个引用能到达、但从另一个引用到达不了的提交。
- 适用处: 仅限两个引用;省略一边,Git 就假定为 HEAD。
- 三点
- master...experiment 显示从任一引用能到达、但不是从两者都能到达的提交。
- 适用处: 配上 --left-right,可以看出每个提交来自哪一边。
- 交互式暂存
- git add -i 打开一个用于暂存、取消暂存、比较差异,以及按补丁粒度暂存文件的 shell。
- 适用处: 补丁模式在它之外也能用,通过 git add -p、git reset --patch、git checkout --patch、git stash save --patch。
另外 31 个概念
- 暂存补丁
- Git 一次显示一个差异块,让你可以暂存文件的一部分,把其余的留在外面。
- 适用处: 会让一个文件同时处于已暂存和未暂存两种状态;提交时只捕获已暂存的部分。
- 储藏
- 把一个弄脏了的工作目录存到一个栈上,这样你可以切换任务,以后再重新套用。
- 适用处: 默认只覆盖已修改和已暂存的被跟踪文件,不含未跟踪或被忽略的。
- 清理工作目录
- 用 git clean 永久删除未跟踪的文件,而不是把它们存起来。
- 适用处: 被忽略的文件除非给了 -x 才会被删,嵌套的 Git 仓库需要再加一个 -f。
- 用 GPG 签署工作
- 给标签、提交和合并附上可验证的密码学签名。
- 适用处: 验证需要签署者的公钥在你的钥匙环里;价值取决于整个团队都签名。
- Pickaxe 搜索
- git log -S 找出那些改变了某个字符串出现次数的提交。
- 适用处: 数的是出现次数的变化;要用正则表达式,就用 -G。
- 重写历史
- 在分享之前,重塑本地的提交——顺序、信息、内容、拆分、删除。
- 适用处: 只在推送之前才安全;重写已推送的提交会让协作者困惑。
- 修补一个提交
- 用一个带着更新后的信息或内容的新提交,替换最后一个提交。
- 适用处: 会改变 SHA-1,所以对已经推送的提交要避免;琐碎的修正可以用 --no-edit 跳过编辑器。
- 三棵树
- HEAD、索引和工作目录,是 Git 在其间倒腾内容的三个文件集合。 HEAD、索引和工作目录,reset 和 checkout 会按顺序改写它们。
- 适用处: 索引是一份压平的清单,不是字面意义上的树结构。 只有 reset 和 checkout 驱动全部三者;status 只是报告它们的差异。
- 暂存区
- 下一个提交的拟议快照,git commit 把它变成一棵树。
- 适用处: 由最近一次检出填充;可用底层命令 git ls-files -s 查看。
- filter-branch
- 一个可脚本化的批量改写器,一次改写许多提交,比如清除某个文件或邮箱。
- 适用处: 陷阱很多,已不再推荐;更推荐 git-filter-repo,而且它会改写其后的每个 SHA-1。
- 带路径的重置
- 给 reset 一个文件,让它跳过移动 HEAD,只更新索引(以及可选地更新文件)。
- 适用处: HEAD 不能只移动一部分,所以带路径时,第 1 步总是被跳过。
- 对工作目录安全的检出
- 检出拒绝破坏已修改的文件,并改为尝试一次琐碎的合并。
- 适用处: 带路径的检出对工作目录并不安全。
- 合并索引阶段
- 冲突期间,Git 在索引里保留三个版本:基底、我们的、他们的,编号 1-3。
- 适用处: 阶段只在冲突未解决时存在。
- 忽略空白的合并
- 一些策略选项,让合并在比较行时忽略空白差异。
- 适用处: 它是忽略而不是修复空白,所以混杂的行尾可能仍然存在。
- Rerere
- Git 记录你是怎么解决一个冲突块的,并在下次自动重新套用那个解决方案。
- 适用处: 只匹配同一个块的冲突;必须通过 rerere.enabled 启用。
- 组合差异格式
- 一种两栏的差异,同时显示每一行相对于合并两侧的差异。
- 适用处: 只出现在有冲突的合并状态里,或用 --cc/git show 看合并提交时。
- 子树合并
- 把另一个项目的历史合并进你项目的一个子目录,而不是使用子模块。
- 适用处: 更复杂,容易重新集成出错,或推送到错误的仓库。
- ours 合并策略
- 一次假合并,记录两个父提交,却只保留你自己分支的内容。
- 适用处: 与 -Xours 递归选项不同;它完全忽略另一侧。
- 文件标注(git blame)
- 显示一个文件的每一行是哪个提交最后改的,带着作者和日期。
- 适用处: 只有当你已经大致知道问题在哪里时才有帮助。
- 隐式的重命名检测
- Git 存的是快照,事后才推断重命名或复制的代码,所以 blame -C 可以把一行追溯到另一个文件。
- 适用处: 是事后的推断,而不是记录下来的重命名元数据。
- git bisect
- 对历史做二分查找:标出坏提交和好提交,测试中点,直到找到第一个坏提交。
- 适用处: 需要一个可靠的好/坏测试;以 git bisect reset 收尾。
- 子模块
- 一个作为另一个仓库的子目录保存的 Git 仓库,被记录为某一个特定的提交。
- 适用处: 父项目只记录一个提交指针,而不是子项目的文件。
- 分离头指针
- 没有本地分支跟踪改动的子模块检出,所以在那里做的提交可能在下一次更新时丢失。
- 适用处: 适用于 git submodule update 之后的子模块检出,除非你先在子模块里检出一个分支。
- submodule.recurse
- 一个配置选项,让 Git 对每个支持它的命令都传 --recurse-submodules,这样子模块就自动跟随超级项目。
- 适用处: 需要 Git >= 2.14,且不适用于 git clone。
- 子模块 foreach
- 在每个子模块里运行同一条任意命令,适合一次性在许多子项目里储藏、建分支或比较差异。
- 适用处: 只对那些在每个子模块里重复执行有意义的命令有用;它不协调超级项目的指针。
- git bundle
- 把一次推送会发送的所有东西打成一个二进制文件,你可以通过邮件发送或用移动硬盘携带,然后从中克隆或抓取。
- 适用处: 你得自己列出引用或提交范围;不像网络协议,它不会替你算出最小的集合。
- git replace
- 告诉 Git 在引用某个对象时假装它是另一个对象,而不改写任何 SHA-1。
- 适用处: 底下的对象依旧不可变;cat-file 显示的是被替换后的数据,所述的父提交未必是真实的那个。
- 凭据助手
- Git 调用的一个外部程序,用来提供或存储 HTTP 用户名和密码,这样你就不用重新输入。
- 适用处: 各种模式的安全性不同:cache 只在内存里保留 15 分钟,store 以明文写入,keychain/GCM 在磁盘上加密。
- 底层命令
- 像 commit-tree 这样的低层命令,其他 Git 命令在内部使用,不是为日常使用设计的。
- 适用处: 只对不寻常的外科手术有用,比如构造一个没有父提交的基底提交。
- 自定义凭据助手
- Git 能运行的任何可执行文件,通过 stdin 回应 get/store/erase 来提供凭据。
- 适用处: 必须可执行且在 PATH 上;把它命名为 git-* 就可以使用简短的配置语法。
- 带凭据的 URL
- 每个条目一行的纯文本格式,把协议、用户、密码和主机存在一起。
- 适用处: 只有 git-credential-store 和兼容的助手才用它;keychain/wincred/cache 用的是其他格式。
读后感AI 陪读
在 Git 工具里,提交是写给读者看的,不只是给代码看的
从头读到尾,《Git 工具》这一章,是 Git 不再是一组命令、而开始成为一个模型的地方——而本章自己的弧线,从修订版本的选取一路走到一个用 Ruby 手搓的凭据助手,本身就是论证。它先教你如何命名事物:简短的 SHA-1(带着那个著名的狼的笑话,其中那个限定词自然发生的,在紧接着的下一段被悄悄收回——随机碰撞和对抗性碰撞,从来就不是同一个威胁模型)、reflog 简称、用 ^ 和 ~ 表示的祖先关系,以及范围。从命名走到组合:交互式暂存和补丁模式,它真正的论点是社会性的,而不是技术性的——提交是写给读者的。然后是延后的工具:储藏,每个破坏性标志旁边都有一条撤销的车道;clean,那里的文字从描述转为干脆的规则(“总是先运行 -n”),语气一听就是被烧伤过的人。签名作为本章概念上的支点到来——SHA-1 证明内容没有变,而不是证明是谁写的,书诚实地承认,难的部分是社会性的执行,而不是密码学。搜索(跨树的 grep、数出现次数的 pickaxe、log -L),其实是穿越时间而不是空间的搜索,这是任何外部 grep 都做不到的,因为只有 Git 记得文件过去是什么样。重写历史与重置揭密是本章的重心,也是它教得最好的地方:交互式变基的待办列表从最旧的开始读,不是随意的,而是因为它是一个执行脚本,而不是一份日志;HEAD~4 里的差一,不是陷阱,而是你需要一个不打算重写的基底的后果;三棵树把重置从民间传说变成三个看得懂的步骤,带路径的重置跳过第一步,而检出保持对工作目录的安全。高级合并、blame 与 bisect、子模块、打包、replace 和凭据助手,然后从模型向外走到凌乱的边缘——而子模块那几节,正是书的乐观语气诚实地变薄的地方:抓取却不更新、分离的头指针、push --recurse-submodules、手工合并分叉的指针。我要对读者说的是:标志的清单是这一章里最不持久的部分(它已经冻结了一条关于 stash save 被弃用的邮件列表笔记,却在别处仍然教着 stash save --patch),而心智模型是最持久的。如果你内化了三棵树、把变基当作重放脚本的读法、完整性与真实性的分离,以及在每条破坏性命令之前问“我的撤销车道是什么?”的习惯,具体的语法就成了查表。本章唯一一贯的盲点,是它无可挑剔地解释了 Git 的名字,却从不承认其中有些就是起得不好——^ 和 ~ 在 n=1 时重合,是设计出来的十年困惑,而再多正确的阐述,也修不好一个读起来就不对的 API。为了模型去读它;标志留给手册页。
在 FeedLitra 里读第 7 章,每段旁边都有这些卡片。
开始读书第 8 章:自定义 Git
钩子怎样把自定义延伸到配置文件之外?
第 8 章是怎样展开的AI 辅助
开头:配置作为分层的默认值。 本章从自定义最熟悉的一层——配置文件——开始,并立刻确立它的支配逻辑。Git 依次读三个文件:整台机器的,然后是每个用户的,然后是仓库内部的,后读的各自覆盖之前的。作者把三个命令行开关对应到这三个文件上,并指出,你根本不指定层级时得到的就是 --local,这也是那句安静的包袱:因为仓库自己的文件最后被读,并且是写入的默认目标,一个项目的选择就悄悄地压过机器或用户设的任何东西。从这个框架出发,本章走过实用的清单——编辑器、提交模板、分页器、签名密钥、全局排除、自动纠正,然后是颜色,然后是外部 diff 和合并工具,然后是空白和换行符的处理。这一段在服务器一侧结束,语气也从规定转向对取舍的推理:完整的对象一致性检查默认是关的,因为对大型推送验证校验和很贵,所以安全是用速度换来的;而拒绝非快进推送本身是有漏洞的,因为用户可以删掉一个分支再把它全新地推回去,这恰是为什么需要另一个拒绝删除的选项,来堵上这个漏洞。
发展:从整仓库设置到按路径的属性。 从配置里转出来是有动机的,而不只是宣布。有些行为无法为整个仓库决定,作者的测试案例是一个机器生成的项目文件:把 pbxproj 模式声明为二进制,既阻止 Git 产出没人看得懂的 diff,也阻止它改写换行符,即使该文件技术上是文本。没有哪个全仓库范围的开关能说“只针对这一种文件”——这个缺口,就是按路径作用域的属性所填补的,使它们成为刚讲过的配置层的天然对应。属性随后扩展到远远超出把东西标成二进制。二进制文件可以被纳入真正的审阅:在比较之前把它们转换成文本,而不是比较字节:让 Word 文档经过文本提取器,Git 就会准确报告被加上的那句话;让图像经过元数据读取器,文件大小和像素尺寸的变化就以普通的行 diff 浮现。作者对这里的天花板很小心——文档内部的格式改动永远不会出现,因为转换器从不输出它们。关键字展开得到类似机械的解释:由于 Git 对文件内容做校验和来决定提交是什么,文件在提交存在之前,无法含有它自己的提交信息,所以出路是一对过滤器——clean 在暂存时把占位符还原成它的光秃形态,smudge 在检出时代入真实的值——使存储的内容保持稳定,而工作副本看起来是展开的。用于归档的 export-ignore 和 export-subst,以及按文件的合并驱动,补全了这一节。
转折:从配置到强制执行。 手握两种配置机制,本章从设置转向在恰当时刻运行的程序——钩子,先在客户端一侧概览(提交、电子邮件和杂项),再到服务器(pre-receive、update、post-receive)。这个概览是有意预备性的;它的存在,是为了给收束本章的那个长示例铺垫。
收尾:一台强制执行政策的服务器,然后是客户端的早期预警。 本章的回报是:政策属于服务器,在那里钩子才真能拒绝一次推送——而两个服务器钩子的区别,在于它们能拒绝得多细。其中一个从标准输入收到被推送引用的整张列表,以非零退出码,一次就杀掉整个推送;另一个被交给单个引用名及其新旧修订,每个分支运行一次,所以能只拒绝那一个分支。作者在这个粒度上继续搭建:列出一次推送引入的提交,让钩子检查每条消息是否有要求的模式,读一个访问控制文件,让它拿每个提交碰过的文件,去比对该用户被允许写入的路径。被拒绝的推送会打印政策消息,Git 报告钩子拒绝了这次更新。但本章并不止于不行。只在推送时才被拒绝的工作,得在事后重写——用作者的话说,极其令人沮丧——所以同样的检查也被装到本地:commit-msg 钩子当场中止格式糟糕的消息,而带着预期引用标记的则放行,pre-commit 钩子检查访问控制列表,pre-rebase 钩子守护历史,提交模板则让要求的格式一开始就更容易遵循。
结尾那一节隐含的两点警示。 这两种机制走得都没有看上去那么远。只有属性的规则随代码一起提交;它们点名的辅助程序不会,所以一台缺少过滤脚本的机器上的克隆,就是得不到展开——这就是作者警告,这类过滤器应写成优雅地失败,而不是让检出崩掉的缘故。同样,本地钩子在克隆时不会被复制,必须手动安装,这意味着 commit-msg 和 pre-commit 检查是给个别开发者的早期预警,绝不是全团队的义务。这条规则只有在服务器端的钩子也检查它时,才变得具有约束力——恰是本章最后那个示例所演示的分工。
关键论点,以及支撑它们的原文
Git 的配置是分层的,离项目最近的那个设置才真正生效。
作者把三个开关对应到三个文件上——system 标志对应整机文件,global 标志对应用户主目录里的文件,local 标志对应仓库内部的文件——并指出,你根本不指定层级时得到的就是 local,所以仓库自己的选择悄悄压过更宽泛的默认值。
服务器上接收端的设置,是为了保护仓库的对象及其历史免遭破坏性推送。
作者推理的是取舍,而不只是开处方:每次推送都做完整的对象一致性检查,默认是关的,因为对大型推送验证校验和代价高,所以安全是用速度换来的。他还表明,单靠拒绝非快进推送是有漏洞的,因为用户可以删掉一个分支再全新地推回去,这就是为什么需要那个拒绝删除的选项,来堵上这个漏洞。
有些行为必须逐个文件决定,而不是为整个仓库决定,这就是按路径作用域的属性所填补的缺口——刚才描述的配置层的天然对应。
作者的案例是一个机器生成的项目文件:把 pbxproj 模式声明为二进制,既阻止 Git 产出没人看得懂的 diff,也阻止它改写其换行符,即使该文件技术上是文本。没有哪个全仓库范围的开关能表达“只针对这一种文件”。
另外 3 个论点
二进制文件可以通过在比较之前把它们转成文本,而不是比较字节,被纳入有意义的审阅。
让 Word 文档经过文本提取器,Git 就会准确报告被加上的那句话;让图像经过元数据读取器,文件大小和像素尺寸的变化就在普通的 diff 里变得可见。作者对限度说得很明白:文档内部的格式改动永远不会出现,因为转换器从不输出它们。
政策属于服务器,在那里钩子才能拒绝一次推送——而两个服务器钩子的区别,在于它们能拒绝得多细。
其中一个钩子在输入上收到被推送引用的整张列表,以失败码退出,一次就杀掉整个推送;另一个被交给单个引用名及其新旧修订,每个分支运行一次,所以能只拒绝那一个分支。作者接着在此基础上搭建:列出一次推送引入的提交,让钩子检查每条消息是否有要求的模式,读一个访问控制文件,让它拿每个提交碰过的文件,去比对该用户被允许写入的路径。被拒绝的推送会打印政策消息,Git 报告钩子拒绝了这次更新。
光让服务器说不还不够;同样的检查,值得先在开发者的机器上跑一遍。
这直接承接刚搭起的服务器端拒绝:只在推送时才被拒绝的工作,得在事后重写,作者称之为极其令人沮丧。所以他把消息检查也装到本地——消息不对的提交当场中止,带着预期引用标记的则放行——并指出,用模板预填消息,能让要求的格式一开始就更容易遵循。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么最近的配置文件获胜
Git 依次读三个配置文件——整台机器的,然后是每个用户的,然后是仓库内部的——后读的各自覆盖之前设的值。因为仓库自己的文件最后被读,又是写入的默认目标,一个项目可以悄悄地压过机器或用户设的任何东西。
机制 2
先转换再对二进制文件做 diff
Git 不去比较原始字节,而是可以把二进制文件交给一个辅助程序,比较返回的可读文本。这就把不透明的“二进制文件不同”变成了普通的行 diff,但这个 diff 只能显示转换器选择输出的东西,所以它丢掉的任何东西,依然看不见。
机制 3
为什么关键字盖章发生在检出时
Git 对文件内容做校验和来决定提交是什么,所以文件在提交存在之前,无法含有它自己的提交信息。出路是一对过滤器:一个在文件被暂存时把占位符还原成光秃的形态,另一个在文件被检出时代入真实的值,这让存储的内容保持稳定,而工作副本看起来是展开的。
常见误读
既然 .gitattributes 随代码一起提交,任何克隆这个项目的人就会自动得到同样的过滤和关键字展开。
只有规则随仓库一起走;它们点名的辅助程序不会。一台缺少过滤脚本的机器上的克隆,不会得到展开,这就是作者警告,这类过滤器应写成优雅地失败,而不是让检出崩掉的缘故。
写一个 commit-msg 或 pre-commit 检查,就能让整个团队遵守规则。
第 8 章的核心概念
- git config 的层级
- Git 依次读取的三个文件——系统级、用户级、仓库级——越近的文件越优先。
- 适用处: 只涵盖存在配置文件里的设置;按路径的行为则需要属性。
- Git 属性
- 写在 .gitattributes 里的按路径设置,告诉 Git 怎样对特定文件做 diff、合并或过滤。
- 适用处: 作用域是仓库里的路径;用 .git/info/attributes 可以让它们不被提交。
- core.autocrlf
- 在提交时把 CRLF 换行符转成 LF、并可在检出时转回去的设置。
- 适用处: true 适合 Windows,input 适合 Linux/macOS,false 适合纯 Windows 项目。
- 外部 diff 和合并工具
- 包装脚本加上 merge.tool/diff.external 设置,把 diff 和冲突引向一个 GUI 程序。
- 适用处: 许多工具是预设的,无需 cmd 配置;窗口式工具在纯终端会话里会失败。
- textconv 过滤器
- Git 在 diff 之前运行的、把二进制文件转成可读文本的程序。
- 适用处: 它只会露出转换器输出的东西;.docx 里格式的改动依然看不见。
- clean 与 smudge 过滤器
- 成对的脚本:clean 在文件被暂存时运行,smudge 在文件被检出时运行。
- 适用处: .gitattributes 文件随项目一起走,驱动程序却不会,所以过滤器必须优雅地失败。
另外 7 个概念
- ident 属性
- 让 Git 在检出时,把 $Id$ 字段替换成 blob 的 SHA-1。
- 适用处: 那是 blob 的 SHA-1,不是提交的,而 SHA-1 无法按新旧排序。
- export-subst
- 标出那些文件:git archive 用 git log 的 pretty 格式化器填入其中的 $Format:…$ 占位符。
- 适用处: 生成的归档适合部署,不适合继续开发。
- merge=ours 驱动
- 按文件的合并策略,保留你的版本而不报告冲突。
- 适用处: 对 database.xml 这类绝不该采纳另一分支内容的环境文件有用。
- Git 钩子
- 在某些重要动作发生时 Git 触发的自定义脚本,分为客户端和服务器端两组。
- 适用处: 客户端钩子在克隆时不会被复制,所以强制执行政策应放在服务器上。
- update 钩子
- 一个在服务器端、对每个被推送的分支运行一次的脚本,接收引用名及新旧修订;以非零退出则拒绝这次推送。
- 适用处: 只在服务器上按引用运行;要识别推送者,需要 SSH 加上像 $USER 这样的环境变量。
- 强制执行政策的钩子
- 用来强制执行规则的钩子,如要求的“ref: 1234”消息模式,或限制哪些用户可以碰哪些路径的访问控制列表。
- 适用处: 这个示例只强制执行 avail 指令,并假定本地用户就是推送用户。
- 客户端政策钩子
- commit-msg、pre-commit 和 pre-rebase 脚本,在本地检查同样的规则,让问题在不得不重写历史之前就浮现。
- 适用处: 钩子不会随克隆传输,所以必须手动分发和安装。
读后感AI 陪读
本章从它必须开始的地方起步:配置,和那一行使整个模型可处理的话——最近的作用域获胜,所以其后的一切都是词汇,而不是概念。从这里起,它向外展开,形状直到最后才显露出来。先是你为自己拧的旋钮(编辑器、颜色、以十分之一秒计的自动纠正延迟,这是个小小的坦白:Git 只有在你留给它一个出错的窗口时,才会替你猜)。然后是属性,在那里 Git 不再被告知该做什么,而开始被告知东西是什么。然后是钩子,在那里指令变成了拦截。然后,在最后一段,是这样的承认:这一切都没有约束力,除非它运行在提交者并不拥有的机器上。
属性这一节是本章智识上的高点,它的巧思前后一致:绝不教 Git 认识你的格式,而是教你的格式怎样变成 Git 已经看得懂的东西。Word 文档之所以能 diff,是因为一个转换器露出了它们的文本;二进制文件之所以能 diff,是因为 EXIF 露出了它们的元数据。但每个把戏都在同一口气里说出自己的天花板——格式的改动不会出现,图像 diff 只告诉你它变了,而从不告诉你怎么变。诚实的读法是:过滤器并不揭示改了什么;它定义什么算一次改动。谁写下那行 textconv,谁就决定这个项目被允许注意到什么。
继续阅读
底下坐着那条主宰一切的约束:Git 对内容做校验和,所以内容无法引用它自己的提交。关键字展开、$Date$、对 RCS 的怀旧——全都是在边缘上的变通,而本章直白地这么说,而不是兜售一个特性。同样的坦率,也出现在它承认你无法用肉眼给两个 SHA-1 排序的时候。Git 提供的是身份,不是年代;CVS 难民想要时间,却得不到。属性里的结构性缺陷,也被直接说了出来:.gitattributes 随克隆一起走,过滤器驱动不会。每个克隆都继承了一个它兑现不了的承诺。相关的是:merge=ours 根本不是合并,而是一次宣布了的拒绝,Git 却仍在它上面打印“Auto-merging”——一个值得记住的客气的谎言,因为悄无声息的错误内容,比冲突更糟。
然后是转折,它是本章真正的论证,而不是一个过渡:客户端钩子在克隆时不会被复制。凡是贡献者能删掉的,都是一种礼貌,而不是政策。所以后半部分迁到了服务器上,语气也随之迁移——从“这是怎样帮你的团队”,变成一个解析引用、检查访问控制列表、然后拒绝的 pre-receive 脚本。这一段最妙的地方,是它不停地坦白。名为“pre”的钩子,在补丁应用之后才运行。政策的故事需要一个以 SSH 公钥为键的 shell 包装器,因为大家都是同一个 git 用户,作者身份是一个标签,不是认证。post-receive 钩子无法阻止一次推送,却能让客户端一直挂着——无力拒绝,却仍能浪费你一个下午。而在结尾,那个撕下伪装的词:你的用户将被沙盒化。不是被引导。是被圈住。
我最尖锐的判断:这是一章关于分布式系统里礼貌之局限的文字,却归档在一个听起来像偏好设置菜单的标题之下。Git 的自定义面,恰好在只是建议性的地方宽厚,在具有约束力的地方单薄,而整条弧线,就是作者大声地发现:唯一能强制执行的层,是你的同事够不着的那一层。教学法也与此一脉相承——带着耸肩的全局变量,被雇来当空操作 smudge 过滤器的 cat(因为管线坚持要一个搭档),一个整个调度只是一次参数个数测试的 GUI 合并包装器。这些是脚本,不是软件,在那里用一次性的风格,真的没问题。
我会对读者说的话。把例子敲一遍,但别部署它们:合并包装器里没加引号的 $*,碰到第一个带空格的路径就会坏,而这一类 bug,正是本章自己默许的关于脚本的教训——写一次,在紧急时才被读。把书里关于长标志的建议看得比它听上去更认真——它是这里唯一一句对人而不是对机器说的话,而且说的是遗忘。尽早决定每条规则住在克隆边界的哪一边,并别再把客户端那一边的叫作政策。要么把驱动连同属性一起发出去,要么两样都别发。并注意埋在服务器那一节里的安静回报:你的钩子往 stdout 回显的任何东西,都会到达被拦下的那个人手里。钩子是一条通道,不只是一道闸门,这意味着你拒绝时的措辞,和触发它的条件做的事一样多。自定义 Git,说到底,主要是在给你已决定不信任的同事写消息——所以要像有人会在糟糕的一天读它们那样去写。
在 FeedLitra 里读第 8 章,每段旁边都有这些卡片。
开始读书第 9 章:Git 与其他系统
dcommit 怎样改写本地的 SHA-1?
第 9 章是怎样展开的AI 辅助
本章从一个让步开始,而不是一个论点:不是你做的每个项目都是 Git 项目,也不是每个团队都能被说服迁移。从这个承认出发,本章推出了它的目的。如果你不能把仓库带到 Git 那里,至少可以把 Git 带到仓库那里——把 Git 用作一个通过桥接与外部服务器对话的客户端。开头点出了随后的两半:先是双向桥接,让你在别人的服务器仍然握有权威的情况下继续工作;后面是单向的迁移路径,以历史真正住进 Git 告终。git svn 被介绍为前半部分的旗舰,因为 Subversion 是 Git 用户最可能被困在其中的系统。
如何展开:git svn 的纪律
对 git svn 的处理,是本章最长也最带警告意味的一段,它整个论证都从一个结构性事实往下推:Subversion 只能记录一条线性的历史。所以,Git 所做的任何分支或改写,在桥接的另一边都没有忠实的翻译。实际的后果被写成规则而不是选项——用变基而不是合并,并把 SVN 服务器当作团队唯一的碰头点。作者直言不讳,在 SVN 远程之外再加一个原生的 Git 远程,是典型的错误:同样的工作获得了两个互不兼容的身份,而本来就容易被弄糊涂的 SVN 服务器无法调和它们。准则一节把这一点硬化为强制措施,建议用一个 pre-receive 钩子,拒绝任何缺少 git-svn-id 一行的提交信息。总结性的判断是,git svn 交给你的是一个有意缩减过的 Git:本地分支、暂存区、变基和拣选都真的可用,但严格是作为私人工具,只有一条直线被允许到达服务器。
第一个转向:Mercurial,约束放松的地方
定下这些限制之后,本章转向 git-remote-hg,恰恰是为了展示一个受约束少得多的桥接——对比正是重点。机制按顺序铺开(克隆、refs/hg 映射、notes 映射、忽略),然后是回报:因为 Mercurial 原生理解合并,一次普通的 git merge origin/master 接着 git push,就能直接更新 Mercurial 仓库,演练中一个变更集是在 Mercurial 一侧写的,接下来的几个从 Git 一侧抵达,无需特殊处理。书签对重量级分支,补全了图景。这里只有一个 Git 习惯必须放弃,而它是 Mercurial 根本没有概念的:重写历史。Mercurial 只增不减,所以变基加强制推送,会把重写后的工作作为全新的变更集 8、9 和 10 落地,而被取代的原件原封不动地躺在它们旁边,协作者看到同样的工作两遍。作者把这个教训推广到桥接之外——避免重写已发布的历史,在哪里都是好的做法。
第二个转向:Perforce,从服务器端到客户端
Perforce 被当作坐落在连接两侧的两个不同桥接来处理,本章小心地不让它们混在一起。先是 Git Fusion:一个服务器端的产品,需要管理员的配合、它自己的 INI 配置文件和一张用户映射,把变更集转成提交,再转回来,透明到双方都注意不到对方。然后视角翻转到 git-p4,它完全住在你自己的克隆里,不需要改服务器,并通过 p4 命令行客户端与 Perforce 对话。因为 Perforce 的分支模型与 Git 里的任何东西都不像,git-p4 采用线性化,而不是提交合并提交:同步之后,master 和 p4/master 分叉,git p4 rebase(同步再加一次变基到 p4/master)把它们理顺。提交一个包含合并的分支,三个非合并的提交产生了三个变更集。作者提炼的规则简单得令人愉快——如果你能变基一段历史,你就能贡献它,所以本地分支依然自由——但代价说得明明白白:哪些分支被合并过的记录被丢弃,创建或整合分支仍然需要 Perforce 客户端,而提交会在每个提交信息后追加一行 depot 路径,改变它的内容,从而改变它的 SHA-1,所以你最终得到的提交,并不是你事先分享的那些。
如何收尾:迁移,然后是推广
本章的最后一个乐章把桥接抛在身后,转向单向迁移,它一贯的信息是,一次好的导入值得准备,而不是匆匆来一次 git svn clone。因为 Subversion 的导入既慢又不完美,作者先建一个作者文件,把 SVN 用户名映射到真实姓名和邮箱,并传 --no-metadata,产生带有恰当 Author 字段、没有 git-svn-id 一行的提交——这份元数据,只有你仍打算把提交镜像回 SVN 时才值得保留。接着靠手工收尾:循环遍历 refs/remotes,把远程引用转成真正的 Git 标签和分支,删掉名字里带 @ 的固定修订版引用,以及重复的 trunk 分支,然后用 --all 和 --tags 推送。Mercurial 通过 hg-fast-export 讲得更简短,Perforce 则借助已经讨论过的 Git Fusion 和 git-p4 工具。
收尾的动作是推广到那些有现成导入工具的系统之外。对其他任何东西,作者的建议是把导入写成喂给 git fast-import 的纯文本脚本,他论证说这比用原始的底层命令去组装对象要容易得多。演示是一个简短的 Ruby 脚本,遍历带时间戳的备份目录,对每个快照打印一个 mark、一个日期、一个提交者、一个 deleteall 和内联的文件数据;把它的输出管道接进 fast-import,在单个分支上产生了 4 个提交中的 13 个对象,唯一的平台注意事项是 Windows 需要把 stdout 设为二进制,因为 fast-import 只接受 LF 行尾。于是本章结束在它最赋予人力量的地方:用整章的篇幅讲了别人的服务器所强加的种种妥协之后,它最后表明,任何历史都能被变成 Git 的历史。
关键论点,以及支撑它们的原文
git svn 最好被看作 Git 的缩减版:本地使用它,但让发布的历史保持线性,并把所有协作都经由 Subversion 服务器。
作者的理由是,Subversion 保存单一的线性历史,而且很容易被弄糊涂,所以当团队里混着 SVN 和 Git 用户时,每个人都必须在 SVN 服务器上碰头,而不是在第二个 Git 远程上。为了强制执行这项纪律,他建议用一个 pre-receive 钩子,拒绝任何缺少 git-svn-id 一行的提交信息。
定下了严格的 git svn 规则之后,作者转向 Mercurial,来展示一个受约束少得多的桥接:普通的合并和推送直接就能用。
与本章其他系统不同,Mercurial 自己理解合并,所以一次普通的 git merge origin/master 接着 git push,就能如期更新 Mercurial 仓库——演练中,一个变更集是在 Mercurial 一侧做的,后面几个从 Git 一侧抵达,无需特殊处理。
在 Mercurial 桥接上要放弃的那一个 Git 习惯,是重写历史。
Mercurial 没有对应的操作,所以在变基和强制推送之后,示例以创建了新的变更集 8、9 和 10、而被取代的那些仍留在原地告终——一份让协作者困惑的重复历史。作者把避免重写呈现为普遍的好做法,而不只是这里的。
另外 3 个论点
对 Perforce,规则比看上去的简单:如果你能变基一段历史,你就能贡献它,所以本地分支依然自由。
Perforce 的分支模型与 Git 的毫无相似之处,所以 git-p4 采用线性化,而不是提交合并提交:同步之后,master 和 p4/master 分叉,git p4 rebase(同步加变基到 p4/master)把事情理顺。提交一个包含合并的分支,三个非合并的提交产生了三个变更集,得到线性的结果——代价是哪些分支被合并过的记录被丢掉了,而且创建或整合分支仍然需要 Perforce 客户端。
抛开桥接,转向单向迁移时,Subversion 的导入值得准备,而不是匆匆来一次 git svn clone。
因为导入既慢又不完美,作者先建一个作者文件,把 SVN 用户名映射到真实姓名和邮箱,并传 --no-metadata,得到带有恰当 Author 字段、没有 git-svn-id 一行的提交(这份元数据,只有你仍需要把提交镜像回 SVN 时才值得保留)。之后,必须通过循环遍历 refs/remotes,把远程引用变成真正的 Git 标签和分支,删掉名字里带 @ 的固定修订版引用,以及重复的 trunk 分支,然后才用 --all 和 --tags 推送。
本章以推广作结:对于没有现成导入工具的系统,把导入写成喂给 git fast-import 的文本脚本,而不是手工构建 Git 对象。
作者论证说这比运行原始的 Git 底层命令容易得多,并用一个简短的 Ruby 脚本演示:它遍历带时间戳的备份目录,对每个快照打印一个 mark、日期、提交者、deleteall 和内联的文件数据;把它的输出管道接进 fast-import,在一个分支上产生了 4 个提交中的 13 个对象。在 Windows 上,脚本必须把 stdout 设为二进制,因为 fast-import 只接受 LF 行尾。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 git svn 强制一条直线
Subversion 只能记录一条线性历史,所以 Git 所做的任何分支或改写,在服务器一侧都没有忠实的翻译。因此,只有当你用变基而不是合并、并把 SVN 服务器当作唯一的碰头点时,桥接才管用;再加一个并行的 Git 远程,会让同样的工作有两个互不兼容的身份。
机制 2
Mercurial 只增不减
Mercurial 没有重写历史的概念:一个存在的变更集就留着。所以当你在 Git 里变基并强制推送,穿过桥接时,重写后的工作会作为全新的变更集抵达,而原件原封不动地躺在它们旁边,Mercurial 的队友看到同样的工作两遍。
机制 3
提交会改写你的提交
git-p4 提交时,Perforce 会在每个提交信息后追加一行 depot 路径,这改变了提交的内容,从而改变了它的 SHA-1。你最终得到的提交不是你原来的那些,所以你事先分享的任何东西,都不再与现在本地存在的东西相符。
常见误读
既然 git svn 给我一个真正的 Git 仓库,我就可以完全按 Git 的风格工作——分支、合并、重写——并且也推送到我那些用 Git 的队友共享的 Git 远程。
本地分支、暂存区、变基和拣选确实可用,但只是作为私人工具:到达服务器的必须是通过变基产生的线性历史,所有协作都得经由 Subversion 服务器。作者恰恰因此把这套设置称为残缺的 Git,甚至提议用一个钩子,拒绝没有 SVN 标识的提交。
Git Fusion 和 git-p4 是同一个 Perforce 桥接的两个名字,所以装哪个,得到的能力都一样。
它们坐落在连接的两侧。Git Fusion 是服务器端产品,需要管理员的配合、它自己的 INI 配置和一张用户映射,并把变更集转成提交再转回来,让双方彼此都察觉不到对方;git-p4 则完全住在你自己的克隆里,通过 p4 命令行客户端与 Perforce 对话,不需要改服务器,却无法创建或整合分支,而且会丢弃哪些分支被合并过的记录。
第 9 章的核心概念
- git svn 桥接
- 一个双向适配器,让你在本地使用 Git,而 Subversion 服务器仍是事实的来源。
- 适用处: Subversion 只保留一条线性历史,所以要避免重写历史,或推送到并行的 Git 远程。
- 版本控制系统的桥
- 一些适配器,让一个开发者即使在团队的服务器跑着另一个系统时也能使用 Git。
- 适用处: 只涵盖常见的几种;每个桥接都继承外部系统的局限。
- 远程助手桥接
- Git 调用的一个程序,通过正常的远程接口与非 Git 仓库对话。
- 适用处: 对 clone/fetch/push 管用,但继承种种局限,比如无法从 Git 一侧删除 Mercurial 书签。
- 书签对重量级分支
- Mercurial 的书签行为像 Git 分支;Mercurial 的分支则永远记录在每个变更集里。
- 适用处: Git 复制不了所记录的分支字段,所以重量级分支通过 branches 命名空间来访问。
- Git Fusion
- 一个 Perforce 产品,在服务器端把 Perforce 服务器与 Git 仓库同步。 一个服务器端的服务,把 Perforce 变更集转成 Git 提交,再转回来,让 Git 和 Perforce 的用户彼此从未察觉。
- 适用处: 服务器端产品,有它自己的 INI 配置和用户映射;git-p4 是不需要改服务器的客户端替代方案。 需要服务器管理员的配合;拒绝重写已经推送的历史。
- 本地排除文件
- 一份存在磁盘上、从不提交的、针对单个仓库的忽略清单。
- 适用处: 当你不能把 .gitignore 提交进外部仓库时有用。
另外 5 个概念
- Git-p4
- 一个完全跑在你自己 Git 仓库里的桥接,通过 p4 命令行客户端与 Perforce 对话。
- 适用处: Perforce 仍然掌管源码;不能创建或整合分支,也不能把浅克隆与多个分支混用。
- 浅克隆
- 只导入最新的 Perforce 修订版,而不是整个历史。
- 适用处: 足以把 Git 用作 Perforce 的客户端,但不够做完整历史的工作;要全部内容就用 @all。
- 作者文件
- 导入时,一个 users.txt,把每个 Subversion 用户名映射成合适的 Git 姓名和邮箱。 一个由 “<input>”=“<output>” 规则组成的文件,在转换期间把凌乱的 Mercurial 作者名改写成有效的 Git 身份。
- 适用处: 必须在克隆之前建好;生成名字清单需要 grep、sort 和 perl。 未匹配的作者原样通过,同样的映射风格也能重命名分支和标签。
- fast-import
- 一个 Git 命令,从 stdin 读取简单的文本指令,直接写出 Git 对象,所以你可以为任何系统写脚本做导入。
- 适用处: 它需要 LF 行尾,并期望你提供快照或明确的文件改动;它自己不理解任何外部系统。
- 导入标记
- 在一次 fast-import 运行中附在提交上的整数标识,这样后面的提交可以指回它作为自己的父提交。
- 适用处: 标记只存在于一条 fast-import 流之内,而且必须是整数。
读后感AI 陪读
Git 桥接的每条规则,都源于一个根本的不稳定性
本章是《Pro Git》里唯一一章在论证而不是在指导的,而它在第一段就亮出了底牌:你的同事们正以“黑暗而古老的方式”工作,而 Subversion 桥接是“通向 DVCS 世界的入门毒品”。这个框架支配着后面的一切。本章按阻抗递减的尺度组织——git svn 得到最长、最满是警告的处理;git-remote-hg 需要的警告少得多;Perforce 分成服务器端的桥接(Git Fusion),它把自己藏得很好,以及客户端的桥接(git-p4),它藏不住;然后,在展示了每种桥接要付出多少代价之后,它打开了写着“迁移”的那扇门,带你走过 svn/hg/p4 的导入工具,最后是一个手写的、喂给 git fast-import 的脚本。隐含的论点是,桥接是暂时的,而本章真正的目的地是出口。
把整件事连在一起的,是早先的一句话:你所有提交的 SHA-1 校验和都会改变。git svn 一节里的每一条规则——别往两台服务器推送、保持历史线性、避免合并提交——都是这单一不稳定性的推论,而不是各自独立的建议。同样的逻辑在 Perforce 下重现,那里提交合并提交“毫无意义”,因为 Perforce 的分支模型容纳不了它们:在本章里,变基从来不是风格上的指导,而是翻译上的要求。桥接只携带较窄的那一侧能表示的东西,而安静的、反复出现的代价,是在途中被丢弃的信息——Mercurial 对某个变更集诞生于哪个分支的记录,被一句括号里的“而且不需要”打发掉,那是披着事实外衣的观点;你的分支拓扑,之所以能自由地构建,恰恰因为它在出去的路上会被压平。
继续阅读
本章最有价值的习惯是自我打断。它两次先建起一幅令人安心的图景,然后又猛地把它扯掉:对 log 输出里的假引用说“事实证明,其中有些其实并不存在”,对 Git Fusion 说“这有点误导人;那里其实并没有一个远程”。这是有意的教学法,而且管用——它让你对刚被教着去享受的那种模仿,产生了免疫。同样的坦率也表现在“低效得离谱,但这是唯一简单的办法”里,表现在把 SVN 当服务器你就“始终被束缚住手脚”这句直截了当的裁决里,也表现在你还没学完时,就把 git svn 称为“残缺的 Git”里。文档几乎从不承认自己推荐的路径是荒谬的。与之相对,Mercurial 得到的那一句话的处理,本身就是个论证:共享的数据模型,胜过任何数量的聪明桥接。
我要给出的最尖锐的判断,是关于失败的模式,因为它们不是本章所宣传的那些。这里的危险是无声的。互不冲突的改动可以组合成“一个在你推送时、你的任何一台电脑上都不曾存在的项目状态”,而 SVN 会毫无怨言地发布这个未经测试的组合。穿过 hg 桥接变基并不会报错——它成功了,把旧的变更集留在原地,把永久的困惑交给你的队友。而这些桥接广受宣传的胜利——“你甚至可能不知道另一端是 Mercurial”、“它们谁也不会知道”——也正是隐患,因为不知情恰恰是有人伸手去用强制推送的状态。互相无知只有在没人拿起一件锋利的工具时才是一项功能。我还想指出本章不加评论就略过的一处:把一个提交者改写成“Anon X. Mouse”的作者映射示例。它被呈现为把内部仓库开源时的一种便利;实际上它是一个关于署名归属的决定,值得一句话,却没有得到。(同类的一个较小的乐趣,也没有评论:Git 和 Mercurial 的忽略语法重叠得足够多,修复办法就是把文件复制到 info/exclude——没有谁设计过的兼容性。)
所以,对读者说:用这一章,但要把它的排序当作它的信息来读。学到够你在手头真正做的项目里活下来的桥接知识就行;把每条关于线性历史的指示,当作身份不稳定的后果,而不是偏好;并且别忘了,本章把最后三分之一都用来教你离开。熟练使用桥接不是目标——它是你在等待那场你本该规划的迁移时所需要的东西。当界面感觉天衣无缝时,请记住,书曾两次打断自己,告诉你接缝仍然在那儿。
在 FeedLitra 里读第 9 章,每段旁边都有这些卡片。
开始读书第 10 章:Git 内部原理
HEAD 和索引在这张对象图里处于什么位置?
第 10 章是怎样展开的AI 辅助
开篇:一个揭示,被框定为一次反转。 本章一开始就把读者心里关于 Git 的图景的地板抽掉。迄今学到的一切——add、commit、branch——原来都是“高层命令”,是盖在一种截然不同的东西之上的便利层:一个内容寻址的键值文件系统,上面装了一个版本控制的界面。本章先确立这个区别(底层命令对高层命令),然后通过游览 .git 目录把它落到实处,让抽象有了地址。随后的演示是本章开头的证明:把 test content 管道接进 git hash-object -w --stdin,返回一个四十个字符的哈希,对象出现在磁盘上。然后这个哈希被手工重现——在内容前面加上类型和大小的头以及一个空字节,对整个字符串取 SHA-1——结果完全吻合。这单一的吻合,承载着本章的核心机制:键不过是这些字节的一个函数,所以相同的内容总是落在相同的地址。
展开:一层一层向上构建 Git。 从 blob 出发,本章通过揭示每一层缺少什么,来构造对象模型。blob 装着内容,却没有名字,所以引入树,把模式、类型、文件名和哈希配对——指向 blob 或其他树。树是一份快照,却没有作者、祖先,或存在的理由,所以构造出提交对象,来命名一棵顶层树,外加父提交、身份和信息。然后读者把 hash-object、update-index、write-tree 和 commit-tree 链起来做三遍,得到一份真正的历史,git log 把它显示得就像是正常做出来的一样——这是本章的证据:高层命令自动化的恰恰是这个序列,仅此而已。这份手工搭建的历史,也因为变得痛苦而引出下一步:记住每个版本的哈希不现实,于是引用作为解决办法登场。把一个哈希写进 .git/refs/heads/master,git log master 就立即能用了,这把分支简化为指向某个提交的指针。值得注意的是,本章只演示一次原始的文件写入来说明这个想法,然后就把读者引向 git update-ref 这个受支持的接口——同样的护栏逻辑,让 git symbolic-ref 拒绝把 HEAD 指到 refs/ 之外。
中段的扩展:其余一切都是薄薄的约定。 有了 blob、树、提交和引用,本章的方法就变成了重复:其余每个 Git 功能,都被表明是盖在同一个存储之上的一点小约定。HEAD 是一个符号引用,指名当前的分支。标签是第四种对象类型。远程引用是只读的书签。打包文件是这里唯一真正全新的机制,它纠正了本章一直在铺垫的一种误读:提交存的是完整的快照,而不是差异,所以对一个 22K 文件的一行编辑,会留下两个近乎相同的 blob(22054 和 22044 字节)并排放着。节省空间来自另一个机制——git gc 把 15K 的松散对象重写成一个 7K 的打包文件,完整保留最新的版本,把较旧的那个存成一个九字节的增量,因为最近的版本是你需要得最快的。本章诚实地指出,这些数字来自一个很小的演示仓库。
转折:从存储到传输。 解释完存储之后,本章转向外部,看两个仓库怎样交换它。引用规格提供远程和本地引用之间的映射;哑协议和智能协议提供移动。握手——send-pack/receive-pack 和 fetch-pack/upload-pack——在 SSH 和 HTTP 两种方式下都被走了一遍,演练的要点是,推送不是盲目的文件复制,而是一场协商:带长度前缀的块,每块以一个四字符的十六进制长度开头,然后是旧哈希、新哈希、引用名,全零的旧哈希表示一个不存在的分支。在 HTTP 上,同样的对话只是拆成了对 info/refs 的一次 GET,和一次携带 send-pack 输出与打包文件的 POST。
收尾:维护、恢复,以及删除的局限。 最后的乐章是实用的,它的回报来自前面建起的模型。自动 gc 和 packed-refs 管内务;reflog 和 fsck 管恢复那些看起来已经丢失的东西。然后是最有启发的案例:提交删除一个 5MB 压缩包的操作,什么也没回收——size-pack 仍然报告 4868KB——因为这个 blob 仍可通过旧的提交到达。只有用 filter-branch 重写每一棵过去的树,清理剩余的引用,再运行 gc,才把打包后的仓库降到 8K,即便如此,对象仍可能松散地逗留,直到 git prune --expire,并带着一个警告:重写历史会迫使协作者变基。一份环境变量的清单(路径规格通配、提交身份、HTTP 网络、差异与合并行为、追踪)结束了本章,它结束在开始的地方:懂得了底层命令,就能把 Git 用成它实际的样子——一个内容寻址的文件系统。
关键论点,以及支撑它们的原文
在命令底下,Git 其实是一个内容寻址的键值存储,上面装了一个版本控制的界面。
作者把字符串 'test content' 管道接进 git hash-object -w --stdin,得到一个 40 个字符的哈希,同时对象出现在磁盘上;用 Ruby 对 “blob 16\0what is up, doc?” 手工重现同样的哈希计算,得到的恰恰是 Git 报告的那个哈希,表明键不过是内容的一个函数。
日常命令 git add 和 git commit,不过是低层步骤之上的便利包装,这些步骤一个人可以手动运行。
在手工建好 blob 和树之后,作者把 hash-object、update-index、write-tree 和 commit-tree 链起来做三遍,最终得到一份 git log 正常显示的真正历史——证明高层命令自动化的恰恰是这个序列,而不是做了别的什么。
引用的存在,只是为了让人类能用好记的名字代替 40 个字符的哈希,这把分支简化为指向某个提交的指针。
这直接源于手工搭建历史的痛苦:存储每个版本就意味着要记住它的哈希,作者称之为不现实。把一个哈希写进 .git/refs/heads/master,git log master 就立即能用了,而 git update-ref 在更早的提交上创建一个测试分支——不过作者把读者引向 update-ref,而不是自己去编辑那些文件。
另外 3 个论点
打包对象大致让一个仓库在磁盘上的花费减半。
对一个 22K 文件的一行编辑,留下了两个近乎相同的 blob,22054 和 22044 字节,并排放着;经过 git gc,15K 的松散对象变成了一个 7K 的打包文件,而 verify-pack 显示其中一个版本,作为对另一个的增量,只占 9 个字节。作者指出,这些数字来自一个很小的演示仓库。
推送和抓取不是盲目的文件复制,而是以带长度前缀的块进行的明确协商,双方在任何打包文件移动之前,先宣告各自的引用和能力。
一个 send-pack 交换的样本显示,每个块以一个 4 字符的十六进制长度开头,然后是旧哈希、新哈希和引用名——全零的旧哈希表示之前不存在的分支。在 HTTP 上,同样的对话只是拆成对 info/refs 的一次 GET,和一次携带 send-pack 输出加打包文件的 POST,可能还会进一步包裹在分块传输编码里。
提交删除一个大文件,并不会回收任何空间,因为这个 blob 仍可通过旧的提交到达。
作者加了一个 5MB 的压缩包,在后面的提交里删掉它,发现 size-pack 仍然报告 4868KB;只有在 filter-branch 把它从每一棵过去的树里剥掉、清理剩余的引用并运行 gc 之后,打包后的仓库才降到 8K。他警告说,即便如此,对象仍可以作为松散对象存活,直到 git prune --expire,而且重写历史会迫使协作者变基。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
内容如何成为它自己的地址
Git 在内容前面加上类型和大小的头以及一个空字节,对整个东西运行 SHA-1,用 zlib 压缩,再把它归档在一个按 2/38 拆分的路径下。因为键是对字节本身算出来的,相同的内容总是落在相同的地址,这就是这个存储成为键值文件系统的原因。
机制 2
三种对象类型叠在一起
blob 只装着没有名字的原始内容,所以树加上把模式、类型、文件名和 blob 或子树的哈希配对的条目,然后提交指名一棵顶层树,外加父提交、身份和信息。每一层补上它下面那一层所缺的,于是提交最终指向的是一份完整的快照和它的祖先,而不是一份差异。
机制 3
为什么打包能缩小数据库
松散存储把一个文件的每个版本都存成单独的完整对象,所以一行的编辑会把整个文件复制一份。打包把它们重写成一个二进制文件,完整保留最新的版本,把较旧的存成对它的增量,这很划算,因为最近的版本是你需要得最快的。
常见误读
既然分支只是一个装着哈希的小文件,移动或创建一个分支的自然办法,就是打开那个文件,自己把哈希写进去。
作者自己的演示只这样做了一次来说明想法,然后叫读者改用 git update-ref,这是受支持的接口,能让 Git 的簿记保持一致。同样的护栏也出现在 HEAD 上:git symbolic-ref 拒绝把它指到 refs/ 之外的任何地方,回答“Refusing to point HEAD outside of refs/”。
提交存的是自上一个提交以来引入的改动,这就是历史占用空间这么少的原因。
提交对象指名一棵顶层树——项目的一份完整快照——连同它的父提交、作者和提交者数据,以及一条信息。节省空间完全来自另一个机制:打包文件后来把相似的版本重新编码为对最新版本的增量。
第 10 章的核心概念
- Git 内部原理
- Git 命令底下实际的存储机制:.git 目录、对象数据库和引用。 底层命令是给脚本用的低层构件;高层命令是你每天键入的友好命令。 日常高层命令底下的低层底层命令。
- 适用处: 对理解和写脚本有用,但使用日常的高层命令并不需要。 底层命令多半不打算在命令行上手工运行。 对自定义工具有用,普通的日常工作流不需要。
- 内容寻址文件系统
- 一个键值存储,键是对内容本身算出来的哈希,所以相同的内容总是得到相同的键。
- 适用处: 它只存内容——文件名住在树对象里,不在 blob 里。
- blob 对象
- 一块存起来的文件内容,由头加内容的 SHA-1 命名,不附带文件名。
- 适用处: 自身不带名字、模式或历史。
- 树对象
- 一份目录清单:各条目把模式、类型、文件名,和一个 blob 或子树的 SHA-1 配成对。
- 适用处: 由索引状态构建,通常通过 git write-tree。
- 提交对象
- 一条记录,指名一棵顶层树、它的父提交、作者/提交者数据和一条信息。
- 适用处: 提交指向的是整棵快照树,而不是差异。
- 对象存储
- 每个对象都存为:类型和大小的头,加一个空字节,加内容,先 SHA-1 哈希,再 zlib 压缩,存到按 2/38 拆分的路径上。
- 适用处: 所有类型都是同样的方案;只有提交和树的内容有严格的格式。
另外 13 个概念
- Git 引用
- .git/refs 下的普通文件,在一个可读的名字下存着一个 SHA-1,所以分支不过是指向某个提交的指针。
- 适用处: 用 git update-ref 编辑它们,而不是直接写文件。
- HEAD 指针
- 一个存着指向另一个引用的指针的文件,指明你所在的分支。
- 适用处: 它也可能存的是原始的 SHA-1,那就是分离头指针状态。
- 附注标签
- 一个真正的标签对象,带着打标签者、日期、信息和指针,被 refs/tags 引用。
- 适用处: 轻量标签只是一个从不移动的引用;不创建对象。
- 远程跟踪分支
- refs/remotes 下的一个只读书签,记录某个服务器分支上次在哪里。
- 适用处: Git 不会把 HEAD 指向它,所以提交从不更新它。
- 打包文件
- 一个存着许多对象的单个二进制文件,保留相似版本之间的增量。
- 适用处: 无法从提交到达的悬空 blob 不会被打包。
- 引用规格
- 一个 <src>:<dst> 映射,告诉 Git 把哪些远程引用以哪些本地名字来跟踪。
- 适用处: 不能从一个仓库抓取而推送到另一个;开头的 + 允许非快进更新。
- Git 传输协议
- 搬运数据的两种方式:一串哑的 HTTP GET,或者一个懂 Git 的智能进程。 分块的请求/响应之舞,send-pack 与 receive-pack 对话、fetch-pack 与 upload-pack 对话,以决定要运哪些对象。
- 适用处: 哑协议无法把客户端数据写到服务器,如今很少使用。 本章只涵盖基本的一来一回;multi_ack 和 side-band 之类的额外内容被省略了。
- 自动 gc
- Git 悄悄运行的一个内务命令,一旦越过阈值,就会打包松散对象、合并打包文件,并修剪旧的无法到达的对象。
- 适用处: 它通常什么也不做:大约需要 7000 个松散对象或 50 个打包文件,才会触发一次真正的 gc。
- packed-refs
- 一个单个文件,gc 在其中存放引用的 SHA-1;Git 先读 refs/,再回退到这个文件。
- 适用处: 更新过的引用会被写回 refs/heads,而不是在这里就地编辑。
- RefLog 简称
- Git 对 HEAD 每次移动的无声日志,让你能命名并重新到达那些不再有任何分支指向的提交。
- 适用处: 它住在 .git/logs/ 里,所以删掉这个目录就毁掉了这条恢复路径。
- filter-branch
- 一个重写历史的工具,可以从每一棵过去的树里剥掉一个文件,这里用来清除误提交的大对象。
- 适用处: 对提交历史是破坏性的;除非在导入后立即运行,否则协作者必须变基。
- Git 追踪变量
- 一些环境开关,让 Git 把它内部在做什么,打印到 stderr 或一个文件里。
- 适用处: 取值必须是 true/1/2 或以 / 开头的绝对路径。
- 作者与提交者
- 提交上两个独立的身份字段,各有自己的名字、邮箱和日期变量。
- 适用处: 这些变量存在时,优先于配置值。
读后感AI 陪读
你可以伪造一段真实的 Git 历史,因为这里确实没有魔法
本章是《Pro Git》不再描述 Git、而开始拆解它的一章,它的论点全包含在第一句话里:Git 是一个内容寻址的文件系统,在上面写了一层版本控制的界面。接下来的一切,都是耐心的拆解。底层命令与高层命令的划分——一个把马桶比喻拿来当架构词汇的说法——对设计出奇地诚实:友好的命令是装在没人打算让你看见的管道上的卫浴设备,这恰恰就是为什么 Git 的命令界面一直让人觉得像是两个工具叠在一起。然后本章做了少有的书敢做的事:它把管道交给你。你对一个字符串做 hash-object,写一棵树,伪造一个提交,突然你就有了一份真正的历史,而 git log 会欣然把它显示出来。“说来也怪”,作者自得其乐地说,而这句旁白里带着教益——底下没有什么魔法层。这既让人安心,又同时有点惊人。
本章最尖锐的一个动作,是存储层的反转:Git 不存文件名,它存内容,而名字是后来通过树装上去的。一旦你看出这一点,重命名就不再是 Git 实现的一项功能,而成了 Git 压根忍不住要做的事;去重和免费的文件移动,出于同样的原因自然而然地冒了出来。小细节也各得其所——哈希是对类型+大小+NUL+内容取的,而不是对你的字节取的,这恰恰就是为什么任何人头一次手算 Git 的 SHA 都会失败。而分支,在引发了那么多策略之争之后,不过是你用 echo 就能写出来的四十一个字节的文本。HEAD 是一个指向其中一个文件的符号引用。远程分支一句话就从“别处的分支”坍缩为“别人状态的一份只读缓存记忆”。让标签指向一个 GPG 密钥的把戏,是通过富有成效的滥用来证明:对象模型比它自己的界面所承认的更通用。
继续阅读
我最欣赏的是教学法。打包文件只在本章让你感受到把两个近乎相同的 blob 完整存下来有多浪费之后才登场——人为制造的不适,好让解决方案作为解脱落地。增量是倒着跑的,最新的版本完整存储,而这是浇铸在存储里的价值判断:当下便宜,历史要付解压缩的账单。哑协议是沿着它的失败路径走一遍的,包括 404,而不是它的顺利路径。而在恢复一节,作者删掉了他自己的救援分支,然后 rm -Rf 掉 reflog,毁掉了他刚教给你的安全网,用 fsck 强行教一课更难的。大多数教程停在第一个圆满结局;这一章却继续走了下去。
有两条线索在底下安静地延伸,值得点出来。第一,在这个对象库里,可达性是唯一的公民资格测试——一个 blob 可以存在、被哈希、可被寻址,却依然不是仓库的一部分。那个拯救“丢失”提交的机制(它从未丢失,只是没有名字),同样也是让那个 100MB 的压缩包焊死在你历史里的机制,也是让 gc 在几个月后清扫未被引用的对象的机制。所以,本章早先那份“什么都不会丢”的安慰,悄悄获得了一个过期日期。第二,安全网是建立在 Git 盯着你的基础上的:reflog 记录你碰过的每一个 HEAD。既让人安心,又让人隐隐不安。而当 filter-branch 带着“要当心:这个技巧是破坏性的”登场时,本章承认了大多数 Git 讲解都回避的事——Git 的不可变是社会性的,而不是物理性的。你可以改写任何东西;你只是得去告诉每个人。结尾的环境变量目录是参考资料,不是论证,读起来也就是那样。
我要对读者说的是:别略读这一章,要敲一遍。在一个草稿仓库里花三十分钟,手动运行 hash-object、cat-file、write-tree 和 commit-tree,就会用一个真正能预测行为的模型,取代多年积累的迷信。你不会每天都用底层命令,这不是重点。重点是之后 Git 不再是一堆咒语——它们的失败让人觉得随意——而成了一个小而看得懂的系统——对象、引用、可达性——它的失败你能推理,通常也能修复。这种从死记命令到心智模型的迁移,是本章真正的交付,也是它成为《Pro Git》里值得读两遍的一章的原因。
在 FeedLitra 里读第 10 章,每段旁边都有这些卡片。
开始读书附录 A:其他环境中的 Git
哪些工作流,值得放弃命令行更精细的控制?
附录 A是怎样展开的AI 辅助
开篇:终端并非故事全部的前提。 附录一开始,先承认读者刚刚学完的东西——命令行 Git——然后有意地把它相对化。它论证说,Git 通常是一个更大生态系统中的一个组件,而终端并不总是进入它的最佳入口。这把接下来的一切框定为对环境的巡礼,而不是对命令的巡礼。第一站是图形界面,在这里,本章确立了一条它将反复依赖的评价原则:不同的客户端是为不同的工作流而造的,所以没有哪个单纯“比”另一个“更好”;它们只是更适合或不那么适合某个目的。紧接着附上了第二条、起平衡作用的断言——图形界面能做的任何事,都没有超出命令行,命令行保留着最大的威力和控制。这一对说法(图形界面是正当的,却从不是超集),就是本章安静的论点。
展开:图形界面,从面向任务到面向工作流。 论证然后通过举例推进。先是 Git 自带的工具 gitk 和 git-gui,被呈现为面向任务的:gitk 是 git log 和 git grep 之上的图形外壳,用来查找和可视化历史,git-gui 是一个精心制作提交的工作台,左边是索引,右上是块级和行级的差异视图,右下是信息/提交/修补控件。文字总结说,每个都只做一件事,省掉那件事不需要的东西。然后,macOS 和 Windows 的 GitHub 客户端,作为对照的类别,是面向工作流的:围绕“GitHub 流程”——提交到一个分支并经常同步——而搭建的一套精选功能。本章对这个取舍很具体——单个“Sync”按钮把拉取、变基、回退合并和推送压成一个多步动作,恰恰因为去掉了选择而节省时间。它的总结相应地带条件:对它所设计的工作流极好,但如果你的工作流不同,或者你想控制网络操作何时发生,就用别的东西。想要替代品的读者,被指向精选的和全面的外部清单。
第一个转向:从独立客户端到编辑器。 本章随后转换语域,从就是 Git 前端的应用,转向内嵌 Git 的应用。Visual Studio(自 2019 16.8 起)、VS Code(需要 Git 2.0.0+)、JetBrains IDE 家族,以及 Sublime Text(自 3.2 起),各自得到一份压缩的功能清单——克隆、分支、暂存、提交、推送/拉取/同步、解决冲突、查看差异——外加扩展和文档的指引。这里的处理刻意很薄、像目录,有一个细节揭示了背后的逻辑:JetBrains 的集成依赖于已安装的命令行 Git 客户端。它们不是 Git 的替代品,而是盖在它之上的表面。
第二个转向:配置终端本身。 离开命令行走了一圈之后,本章带着不同的提议转回头来——不是放弃 shell,而是改进它。Bash 通过 git-completion.bash 获得补全,通过 git-prompt.sh 获得提示符,PS1 的构造被一块一块地解释。Zsh 被呈现为更强大:补全带着描述,而且可以导航,vcs_info 向左或右提示符提供分支信息,git-prompt.sh 和 oh-my-zsh 框架作为替代方案提供。PowerShell 结束了这个序列,cmd.exe 被斥为无法使用,推荐 posh-git,包括先决条件 ExecutionPolicy 的讨论、仅为一个用户或为所有用户的范围选择、从库中安装和从源码安装的路径,以及关于 Windows PowerShell 的发布者证书不同的排错说明。
收尾。 结尾简短,而且比此前的内容稍微宽泛,声称读者现在可以在日常工具内部挥舞 Git,也可以从自己的程序中触及仓库——是对附录更大弧线所涵盖的编程式集成的点头。从结构上看,本章描出一条向外再折返的弧线:从命令行出发,穿过图形客户端和编辑器集成,再回到一个信息更丰富的终端,“对工作流的适配度”是贯穿始终的一贯衡量标准。
关键论点,以及支撑它们的原文
图形化的 Git 客户端无法互相比较出好坏,只能比较谁更适合或更不适合它们被造出来要做的活。
作者指出,有些客户端有意只暴露 Git 手选出来的一小片,以支持客户端作者认为有效的某一种工作方式,所以缺少的功能是设计上的选择,而不是缺陷。成对的例子让这一点具体起来:gitk 是挖掘过去历史的查看器,git-gui 是组装提交的工作台,每个都省掉了另一个所需的东西。
承认了这一点之后,作者坚持认为,命令行仍然是天花板:没有哪个图形工具扩展了 Git 能做的事。
新的 Git 功能首先出现在终端里,作者直言不讳地说,图形客户端能做的任何事,命令行客户端都能做,这使终端成为对仓库拥有最大威力和控制的地方。所以图形工具买到的是舒适和视觉上的清晰,而不是能力。
这个天花板就是面向工作流的客户端的便利要以控制为代价的原因,而作者告诉你,当你需要那种控制时,就离开它们。
GitHub 的桌面客户端把推送、抓取、合并和变基并成单一的 Sync 动作,这对它们瞄准的“分支并经常同步”的风格来说很快;但作者的收尾建议是明确的:如果你的工作流不同,或者你想决定网络操作如何、何时进行,就应该用另一个客户端或命令行。
它是怎样运作的
本章论证所依靠的 2 个机制。
机制 1
一键 Sync 如何工作
Sync 按钮藏着一个固定的序列:它先尝试在把你的本地提交变基到远程提交之上的同时拉取,如果合并冲突阻碍了这一点,就改为按普通合并重新拉取,然后推送。因为这是以那种风格工作时网络命令通常的顺序,把它折成一次点击,省去了重复的键入。
机制 2
为什么 shell 能显示分支信息
Git 附带了 shell 的辅助脚本,却不激活它们。你要从你自己那个 Git 版本的源码树里复制补全文件,并从你的启动文件加载它,这会教 shell 认识 Git 的子命令、选项、远程和引用名;第二个脚本提供一个函数,你把它嵌入提示符字符串,这样 shell 每次绘制提示符时,都会重新计算分支和脏状态的文字。
常见误读
GitHub 的桌面客户端与 GitHub 绑定,所以你需要在那里有个账号,而且只能用在由 GitHub 托管的仓库上。
作者指出,你不需要 GitHub 账号就能用这些工具:它们的建造是为了展示 GitHub 的服务和推荐的工作流,但它们能用于任何仓库,并对任何 Git 托管方执行网络操作。你也可以把一个现有本地仓库的文件夹拖进窗口,开始跟踪它。
既然 Git 自带 shell 支持,安装了 Git 就该马上有 tab 补全和能识别分支的提示符。
shell 插件随 Git 一起提供,但在你手动打开之前一直处于未激活状态——要从你所装版本的源码里复制补全和提示符脚本,从你的 shell 启动文件加载它们,在 Zsh 里还要运行补全初始化那一行。只有 PowerShell 得到了一个打包好的替代方案;旧的 Windows 命令提示符无法支持这种体验。
附录 A的核心概念
- 面向任务的 Git 工具
- 为一件事而造的工具,比如查看历史或制作提交,省掉其他一切。
- 适用处: 只在它狭窄的目的之内有用;更广的工作需要其他客户端。
- 面向工作流的 Git 客户端
- 一个暴露精选功能集的客户端,这些功能是为适应某一种工作方式而选的。
- 适用处: 如果你需要控制网络操作何时发生,就不合适。
- GitHub 流程
- 提交到一个分支,然后经常与远程同步。
- 适用处: 假定频繁同步;其他分支模型需要不同的习惯。
- Shell 的 Git 集成
- 补全脚本和提示符助手,在 Bash、Zsh 或 PowerShell 里呈现分支和状态信息。
- 适用处: 随 Git 一起提供,但默认关闭;cmd.exe 无法支持。
另外 0 个概念
读后感AI 陪读
Git 工具的差别不在力量,而在哪些想法变得可想
这个附录读起来像博物馆的一翼:每件展品在安装时都是准确的,而标签老化的速度各不相同。它真正的思想内容不是工具目录——而是一套隐含的界面理论:工具的区别不在于威力,而在于它们让 Git 的哪个子集变得可以设想。本章慷慨地陈述了这一点(没有哪个工具“更好”),随即又把它削弱了(命令行威力最大),而同步按钮就是这种张力变得具体的地方:把 pull --rebase / pull --no-rebase / push 压缩成一次点击,不是简化,而是替你做出的一个策略决定,所产生的历史取决于碰巧有没有发生冲突。别处的行文对抽象的泄漏是诚实的——把“Amend”描述为替换一个提交,gitk 用红点表示尚未成为提交的东西——在这些时刻,界面必须对数据模型稍微撒点谎,才好用。最弱的部分,是被历史超越了的那些:GitHub Desktop 被描述成两个产品,一个补全文件要靠检出一个 Git 源码标签来获取,Visual Studio 那份按版本把关的功能清单,其实是一份变更日志。而结尾的总结悄悄宣传了一节关于编程式 Git 访问的内容,它已经不存在了——最清楚的证据,表明这是一章靠累积和修剪拼起来的,而不是整体写成的。持久的洞见是它几乎说出、却没说出的那个:有意思的轴不是图形对文本(Zsh 所描述的补全,比大多数图形界面更易于发现),而是精选对完整。
在 FeedLitra 里读附录 A,每段旁边都有这些卡片。
开始读书论证在哪里吃力
不是反驳,而是细心的读者会想追问的地方。
- 廉价的分支解决了机械层面的问题,却把代价转嫁到人的协调上:重命名默认分支,成了跨越依赖项目、CI 配置、构建脚本、主机、文档和未决拉取请求的一份检查清单。
§3.3 Switching branches changes files in your working directory阅读这一节 →
- 本书把重写历史赞美为一门手艺——补丁级暂存、变基、整洁的逻辑提交——然而在只增不减的持久性、拉取请求文化和被桥接的仓库里,它又禁止重写,因为不论社会性的记录还是外部服务器,都吸收不了它。
- 有意的暂存换来精确,却增加了一个步骤,用户随后又用 -a 这个捷径绕开它,而它悄悄地重新引入了暂存本来要取代的、整个工作树式的提交。
- “分布式而没有必须的中心”的承诺,在实践中被这样的协作抵消了:它重新立起一个权威的托管仓库,而 GitHub 的约定,靠把分布式 Git 重新集中到一个枢纽上,才使它能大规模使用。
另外 4 处张力,以及 8 条证据上的局限
- 协议和服务器的选择,在速度与安全之间权衡,没有通则:最快的协议,恰恰是允许路由器层面的攻击者注入代码的那个,而推送时的校验和验证默认是关着的,因为在大型推送上代价昂贵。
- 快照存储靠放弃显式的历史换来持久性,所以重命名只能事后从内容推断,而不能作为意图被记录。
- 分层配置和逐文件属性,使团队的工作流顺畅,同时使正确性变得不可预料,因为最近的或最后读到的值悄悄获胜,而当一个方便的设置让你提交到错误的分支时,没有任何东西警告你。
- 本书敦促掌握底层命令,同时又认可那些有意把大部分底层隐藏起来的精选工具,而它“工具只是适合程度不同”的说法,同它把命令行排在所有工具之上的做法,别扭地并存。
证据上的局限
- 大部分论证建立在对设计的推理和示意性的命令记录上,而不是测量:速度的说法,是靠同复制目录的老工具对比来断言的,一种工作流优于另一种,是被论证的,从未被基准测试。
§3.3 Switching branches changes files in your working directory阅读这一节 →
- 演示用的是小小的、刚初始化的玩具仓库,所以大规模、巨大文件或很长历史下的行为,从未展示过。
- 用来教内部原理的手工操作对象的工作流,被承认是不切实际的,所以它只是说明模型,而不是值得建议的做法。
- 关于服务器的指导是留有余地的、针对具体工具的:关于 Git 协议的警告,承认 HTTPS 除非证书被伪造,可以避免注入,留下无法量化的剩余风险,也没有哪种配置被说成对所有团队都正确。
- 协作建议是从稀薄的样本里概括出来的:靠添加提交来评审,只用一个单行、单文件的改动演示,而短哈希的安全,只靠一个异常庞大的项目的一份快照,而不是碰撞的界。
- 文本转换式的差异,被表明能抓住新增的文字,却承认会漏掉格式上的改动,所以它对二进制文件给出的是部分而不是完整的可见性。
- 关于桥的指导是以习惯的形式陈述的,主要靠强制推送之后什么会坏来说明,并收窄为通过外部服务器协作,没有顾及需要把 Git 和旧服务器都当作活端点的团队。
- 周边的材料是一份过时的快照:对特定 GUI、IDE 和 shell 的判断,随这些产品的变化而过时;正文只标注了一个开源项目的某个提交哈希;而“一章就足以让读者有用”的说法,靠的是作者们自己的玩笑。
全书概念导图
5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。
1 · Git 保存的是快照,不是差异
- 每个提交都保存项目的完整画面
- 每个提交保存所有文件的一幅画面,对没有改动的只链接而不重复存储:blob 保存没有名字的内容,tree 把模式、名字和哈希配对,commit 指名一棵树,加上父提交、作者和提交者。因为不存储重命名的元数据,重命名是事后从内容推断出来的。原书用语: Snapshots, not differences (blobs, trees, commits)
- 底层是一个按自己的哈希来归档内容的存储
- Git 命令之下的存储机制:一个以内容本身的哈希为键的键-值存储,每个对象是一个“类型加大小”的头部再加内容,经 SHA-1 哈希、zlib 压缩,存到 .git 里一条“2/38”拆分的路径下。原书用语: Content-addressable object database
- 每次克隆都持有整个历史,所以工作可以离线
- 版本控制随时间记录文件的改动,这样以前的版本可以被取回;在 Git 的分布式设计中,每次克隆都镜像整个仓库及其完整历史,所以每一份副本都是备份,大多数工作不需要服务器,代码可以在许多对等方之间流动,而不只是经过一个枢纽。原书用语: Distributed version control
- 给脚本用的低层命令,给人用的友好命令
- 底层命令是适合写脚本的低层构件;高层命令是叠在它们之上的、友好的日常命令。原书用语: Plumbing and porcelain
2 · 有意地构造每一个提交
- 文件处在三个地方之一:已编辑、已排队、已保存
- 一个文件是已修改、已暂存或已提交,分别对应工作树、暂存区和 Git 目录;已跟踪的文件,是 Git 从上一次快照或从暂存中得知的文件,而 .gitignore 的通配符模式,告诉 Git 其余的文件里,哪些要完全跳过。原书用语: Three states and file tracking
- 挑出你想要放进下一次保存的确切几行
- 一个存放区,你在那里确切地组装下一次提交所记录的内容;文件或个别的差异块可以一次暂存一个,而 -a 则为已跟踪的已修改文件省去这一步。原书用语: Staging area and patch-level staging
- 一个提交一个想法,分享之前先修好上一个
- 使改动可评审的习惯——每个提交一个逻辑变更集,没有空白错误,一个约 50 个字符的祈使句式摘要加正文——连同一个提交所携带的两种身份(作者和提交者),以及修补,它会整个儿替换最后一个提交。原书用语: Commit hygiene and amending
- 用短名字或范围指向过去的提交,而不用完整哈希
- 不用完整哈希就指向提交的办法——SHA-1 的短前缀,像 HEAD@{5} 这样的引用日志名,用 ^ 和 ~ 表示的祖先——以及双点范围(从一个引用可达、但从另一个引用不可达)和三点范围(从其中任何一个可达、但不是两者都可达)。原书用语: Commit naming and range syntax
3 · 分支很廉价,合并各不相同
- 分支只是一个指名某个提交的小文件
- 用一个可读的名字,保存一个提交校验和的普通文件——分支是一个 41 字节的可移动指针,HEAD 指明你所在的分支,而附注标签是一个真正的对象,带着打标签者、日期、信息和签名。原书用语: References: branches, HEAD, and tags
- 有时指针滑过去;有时 Git 把工作缝起来
- 当被合并的提交正好在前面时,Git 只是把指针往前滑;当历史分叉时,它把两个顶端同它们的共同祖先,合并成一个有多个父提交的提交。原书用语: Fast-forward and three-way merge
- 重放提交以得到整齐的一条线,决不在共享的历史上这样做
- 把一个分支的提交重放到另一个分支上,以得到线性的历史;重写别人已经持有的提交,逼得他们重新合并,并造成历史重复。原书用语: Rebase and its perils
- 保留稳定的线,再为每个新想法开短命分支
- 一个团队的分支安排:稳定的长期的线,加上持有一个工作主题的短命分支,它们可以被单独评审、重写或丢弃。原书用语: Long-running and topic branches
4 · 和团队共享工作
- 挑一种适合你团队规模的传递代码的办法
- 在人与人之间传递代码的安排菜单:每个人都往里推送的集中枢纽,从开发者的公开克隆里拉取的集成管理员,向仁慈的独裁者提供东西的副手,GitHub 的“复刻-分支-推送-拉取请求”循环,以及没有共享服务器时邮寄的补丁系列。原书用语: Collaboration workflow models
- 请维护者拉取你的工作,并在那里讨论它
- 请维护者合并你克隆或复刻里的工作的一个请求——要么是带着差异、逐行评审和讨论的网页讨论串,要么是 git request-pull 的输出。原书用语: Pull request
- 大家共享的一份服务器上的裸副本,小心地开放
- 裸仓库只含有 .git 的数据,没有工作目录,纯粹充当接收推送的协作点;本地、HTTP、SSH 和 Git 协议以不同的权衡到达它——SSH 密钥标识用户,git-shell 限制登录,不认证的守护进程服务 9418 端口,而智能 HTTP 把认证交给网络服务器。原书用语: Shared repository and transfer protocols
- 本地的昵称,以及追踪托管副本的指针
- 远程是别处托管的项目版本;远程跟踪分支记录它的分支在上一次联络时的位置,跟踪分支把本地分支同上游接起来,而“源:目的”形式的引用规格说明哪个本地分支更新哪个远程分支。原书用语: Remotes, tracking branches, and refspecs
5 · 调校 Git,桥接别的工具
- 把默认值设在它该在的地方;最近的设置获胜
- 系统、全局和仓库的配置文件按顺序读入,最近的文件(以及最后读到的值)决定生效的设置——包括 init.defaultBranch——而逐路径的 .gitattributes 条目,则对特定文件覆盖仓库范围的行为:怎样做差异、合并、过滤,或处理行尾。原书用语: Layered configuration and Git attributes
- 一个让你在另一个工具的仓库里工作的适配器
- 一个适配器——git svn、Mercurial 远程助手、Git Fusion 或 git-p4——让开发者用 Git 工作,同时另一个系统的服务器仍是事实的来源。原书用语: VCS bridge
- 把 Git 放到你工作之处的编辑器、客户端和 shell
- 为一件事而造的面向任务的工具,暴露精选功能子集的面向工作流的客户端,以及在 Bash、Zsh 或 PowerShell 里显示分支和状态信息的补全或提示符脚本。原书用语: Git tooling and shell integration
读后感AI 陪读
关于分支的一句话,让 Pro Git 的螺旋穿过所有层次
从头读到尾,Pro Git 是一本关于一个主张和它的后果的书:Git 是一个按内容寻址的键-值存储,上面写了一个版本控制的界面,一旦这个底层可见,几乎一切让用户困惑的东西就都消解了。脊梁沿着简介自己宣布的一道不断放宽的螺旋走——一个自足的核心(基础),你单靠它就能生存,然后是一章转换(分支),其中的一句话“分支是一个轻量的可移动指针”,是整本书的铰链,然后是可选的深度,书一直告诉你哪些部分可以跳过。运动是从对象,到约定,到政治,再回落到内部原理:廉价的分支使短命的主题分支切实可行,这把困难推向外部,推向远程(远程状态只是缓存的快照)和重写(变基以替换提交为代价换得更干净的历史,因此只对未共享的工作是安全的);服务器一章随后把基础设施泄成一个裸仓库加一个选定的传输协议,是一份菜单,而不是正确答案;分布式 Git 承认,一旦 Git 去掉了通往任何拓扑的技术障碍,填满真空的就是社会性的东西——决定贡献方式的是写权限,而不是架构;GitHub 展示了叠在普通 Git 之上的约定,直到这个网站成了可编写脚本的平台;Git 工具和自定义 Git 把一切重新框定为对三棵树和三层策略的操作,其中最局部的设置获胜,钩子把约定变成强制;桥接一章承认每个外部系统都强加它自己在 Git 一侧的纪律;而内部原理最终展示了这一切之下的地板。最尖锐的判断是:Git 与众不同的特点是它的数据模型,而不是界面,它的命令表面是叠在一个干净存储之上的历史偶然——这就是为什么书必须不断地教这个模型,好让这些命令可以忍受;自定义 Git 里章节的安排本身就是一个论证,一条单一的覆盖规则展开成一套词汇;策略的强制,只有在服务器端的拒绝由客户端钩子来呼应时才行得通,这使用户免于痛苦的历史手术;以及附录里诚实的、略微自我拆台的承认:工具的差别不在能力,而在它们让 Git 的哪个子集成为可想象的,命令行仍然胜出。我想对读者说的是:不要把它当作需要时查阅的参考。把基础和分支当作一个连续的论证来读,直到指针模型显得显而易见,然后早点读内部原理,而不要留到最后——第二遍读,书要容易得多,因为最后一章解释了第一章。这两极之间的一切是菜单,不是课程:按团队的规模和访问规则来选择你的工作流、服务器和工具,而不是按读起来像最佳实践的东西。并带上书挣得、而不是断言的那条持久的规则——Git 几乎从不丢失你提交过的东西,所以唯一真正的危险,是重写别人已经拥有的历史。
网络书评怎么说
各英文博客和豆瓣上的评论者看法趋同:Pro Git 写得清楚、节奏得当、插图丰富,分支各章和结尾的内部原理一章被单独点出来作为亮点。读者说它带他们从基础走到自信的日常使用,至今仍是一本方便的参考书。有一位评论者提醒说,它的节奏适合有经验的开发者,不适合新手。
综合已发表书评的看法;不展示评分,完整书评请点来源链接。
来源与授权
在 FeedLitra 里一边读《Pro Git(精通 Git)》,一边看这些卡片。
开始读书