修改了一下午的代码回过头来已经说不清楚动过哪些地方了。git status能告诉你哪些文件发生了变化这就是第一章讲的那张地图但文件里具体是哪几行发生了变化它并不负责回答。回答这个问题的是git diff。这一章的写法看起来有一大串有不加参数的有加--staged的有加HEAD的有后面跟文件名的有跟两个分支名的还有跟两个提交号的。如果分开来背很容易弄混因为它们的输出看起来一模一样。能把它们串联起来的是一件事那就是Git 保存的并不是「改动」而是每一个版本的文件的完整内容。所谓改动是在敲下git diff的那一刻Git 拿两份完整的文件临时比较出来的。因此每一种写法其实都在回答同一个问题那就是拿哪两份内容来比较。一、先把一段输出读懂这一章换用一份稍长一些的配置文件一共 18 行并且已经提交过一次d7aecee 初始配置。在工作区里修改两处地方一处是在第 3 行后面插入一行另一处是把邮件服务器换掉。sed-i3a 重试 3config.txtsed-is/smtp.example.com/mail.example.com/config.txtgitdiffdiff --git a/config.txt b/config.txt index 2559e6f..43a5fe0 100644 --- a/config.txt b/config.txt -1,6 1,7 # 服务配置 端口 3000 超时 30 重试 3 日志 info # 数据库 -14,5 15,5 过期 600 # 邮件 -服务器 smtp.example.com 服务器 mail.example.com 发件人 noreplyexample.com代码 1两处改动输出也分成了两块。当输出的内容比一屏还长时Git 会把它交给less来翻页这时屏幕最下面会出现一个:按q就可以退出如果一屏能显示得下它就会直接打印出来。前提是 Git 把输出接给了 pager在管道或脚本里不会进 less此时无需按 q。开头的四行说明的是「哪两份内容在比较」。diff --git a/config.txt b/config.txt表示这一段比较的是 config.txt 的两个版本前一份叫作 a后一份叫作 b。a/和b/只是给两边起的名字磁盘上并没有叫作a的目录。index 2559e6f..43a5fe0 100644是两份文件内容各自的哈希值再加上文件的类型下一节会专门讲它。--- a/config.txt和 b/config.txt规定了下面正文所用的符号以-开头的行只在 a 里有以开头的行只在 b 里有。这两行本身并不表示删除了什么、增加了什么它们只是一张图例。以开头的行是块头hunk header。一个块就是一段连续的改动区域上一章判断冲突时用的也是这个单位。 -1,6 1,7 的读法是这一块在 a 里从第 1 行开始一共 6 行在 b 里从第 1 行开始一共 7 行。多出来的那一行就是插入的重试 3。第二块的块头是 -14,5 15,5 这一次两个起始行号不一样了。同样是过期 600这一行在 a 里是第 14 行在 b 里却是第 15 行这是因为上面插入了一行下面的内容就整体往后移了一格。所以块头必须给出两套行号a 一套b 一套。图 1两个块各自框住了 a 和 b 里的哪几行。a 的第 7 到第 13 行离两处改动都太远所以不在输出里。块里的每一行只需要看第一个字符开头含义空格上下文两边都有、没动过的行-只在 a 里有只在 b 里有第一块明明只插入了一行却占了 6 行和 7 行这是因为改动的前后各带了 3 行上下文好让人看出改动落在什么位置。离所有改动都超过 3 行的内容不会被打印出来。这个行数可以用-U来调整比如git diff -U1就只带 1 行上下文。如果某一边只有 1 行块头会省略逗号和行数写成 -1 1,2 这样的形式。需要注意的是diff 里并没有「修改一行」这个动作。服务器那一行在输出里是成对出现的a 里的旧行记作-b 里的新行记作。--stat也是按照这种方式来统计的gitdiff--stat# config.txt | 3 -# 1 file changed, 2 insertions(), 1 deletion(-)代码 2插入一行、修改一行被记作增加了 2 行、删除了 1 行。上一章合并完成后打印的config.txt | 2 -用的就是这种格式。如果修改的是代码块头后面还会跟着一段文字比如修改 C 文件时会出现 -11,6 11,6 int main(void)这样的块头。那是 Git 从这一块往上找到的最近一行以字母、下划线或者$开头的内容方便看出改动落在哪个函数里。不过它只是按照这条规则往上查找并不真正理解代码。二、index 那一行两份文件的指纹不是提交号index 2559e6f..43a5fe0里的两个编号很容易被当成提交号。拿git log里的编号对照一下gitlog--oneline# d7aecee 初始配置gitrev-parse HEAD:config.txt# 2559e6fc46f72d35b670bbce9c57265581188ab7代码 3HEAD:config.txt指的是「HEAD 那个提交里的 config.txt 这个文件」。提交号是d7aecee而2559e6f是这个提交里config.txt 这份文件内容的哈希值。在 Git 里保存文件内容的对象叫作 blob按照编号把它取出来得到的是一整份文件而不是一段改动gitcat-file-t2559e6f# blobgitcat-file-p2559e6f# # 服务配置# 端口 3000# 超时 30# 日志 info# ……18 行都在代码 4-t用来查询类型-p用来打印内容。右边的那个43a5fe0更能说明问题。它是工作区里那份文件的哈希值但仓库里根本没有这个对象githash-object config.txt# 43a5fe0ee78266ed7a0542fa0f54428cfeb095a3gitcat-file-t43a5fe0# fatal: Not a valid object name 43a5fe0代码 5hash-object只计算哈希值并不把内容存入仓库。这份内容还没有执行过add所以 Git 手里并没有它。也就是说在敲下git diff的那一刻Git 才去读取工作区里的文件计算出它的编号然后再和 a 那一份逐行比较。并没有任何地方保存着一份「改动记录」等着人来取用。Git 保存的一直都是一份一份完整的快照改动是比较出来的。这也正是 diff 的各种写法可以随意组合的原因只要能说清楚两份快照在哪里就可以拿来比较。末尾的100644表示文件的类型指的是普通文件可执行文件则是100755。如果只修改了权限而没有修改内容diff 就不会输出块只会报告两行即old mode 100644和new mode 100755。只改权限时 diff 无内容块但 --stat 仍可能列出该文件别以为没改动。三、不加参数、--staged、HEAD比的是哪两份第一章说过一次改动需要经过三个地方分别是工作区、暂存区和仓库也就是 HEAD 所指向的那个提交。同一个文件在这三个地方可以各自是一个不同的版本。git diff的三种基本写法就是在这三份内容里挑出两份来比较。下面把第一章的那个场景重新摆出来。从刚刚提交完的干净状态开始先修改一处执行add然后再修改一处。sed-is/超时 30/超时 60/config.txtgitaddconfig.txt# 暂存区里是「超时 60」这一版sed-is/日志 info/日志 debug/config.txt# 工作区又往前走了一步gitstatus-s# MM config.txt代码 6现在三个地方各自是一个不同的版本。不加参数的git diff输出如下diff --git a/config.txt b/config.txt index 0521d10..553b829 100644 --- a/config.txt b/config.txt -1,7 1,7 # 服务配置 端口 3000 超时 60 -日志 info 日志 debug # 数据库 主机 localhost代码 7只有「日志」这一处。a 是暂存区里的那一份b 是工作区。git diff --staged的输出如下diff --git a/config.txt b/config.txt index 2559e6f..0521d10 100644 --- a/config.txt b/config.txt -1,6 1,6 # 服务配置 端口 3000 -超时 30 超时 60 日志 info # 数据库代码 8只有「超时」这一处。a 是 HEAD 里的那一份b 是暂存区。git diff HEAD的输出如下diff --git a/config.txt b/config.txt index 2559e6f..553b829 100644 --- a/config.txt b/config.txt -1,7 1,7 # 服务配置 端口 3000 -超时 30 -日志 info 超时 60 日志 debug # 数据库 主机 localhost代码 9两处改动都在。a 是 HEADb 是工作区。这三个 index 行首尾相接依次是2559e6f → 0521d10 → 553b829。前两条命令各自比较了其中一段而git diff HEAD一步就跨过了整个范围。图 2箭头从 a 指向 b每条命令都只取其中的两份内容。git diff比较的是暂存区和工作区也就是「修改了、但还没有执行add的部分」。反过来说一旦执行了add这部分改动就会从git diff的输出中消失。上一章说git diff --check在执行add之后就查不出冲突标记了必须加上--cached原因就在这里。git diff --staged比较的是HEAD 和暂存区也就是「下一次commit会提交进去的内容」。--cached和它是同一个选项手册的原话是--staged is a synonym of --cached。在提交之前最后检查一遍看的就是这个。git diff HEAD比较的是HEAD 和工作区无论有没有暂存只要是自上次提交以来修改过的内容全都会算上。git status -s输出中的那两个M正好对应前两条命令。左边一列表示暂存区相对于 HEAD 有没有变化也就是--staged有没有输出右边一列表示工作区相对于暂存区有没有变化也就是git diff有没有输出。四、新文件哪种写法都看不见它接下来再往工作区里放一个从来没有执行过add的新文件printf# 部署说明\n先改 config.txt再重启服务。\nREADME.mdgitstatus-s# MM config.txt# ?? README.md代码 10??表示未跟踪。这时再运行git diff HEAD输出和代码 9 一字不差并没有 README.md。也就是说「自上次提交以来的全部改动」里并不包括这个新文件。这里的道理和上一节是一样的diff 只比较 Git 手里的快照。未跟踪的文件既不在暂存区里也不在任何一个提交里图 2 的三个框中都没有它所以哪一条命令都碰不到它。执行add之后它就进入了暂存区于是--staged和HEAD就都能看到它了diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b995e6 --- /dev/null b/README.md -0,0 1,2 # 部署说明 先改 config.txt再重启服务。代码 11执行git add README.md之后git diff --staged输出中 README.md 的那一段。0000000和/dev/null表达的都是「a 这一边没有这个文件」-0,0表示 a 里有零行。删除一个文件时情况正好相反输出里会出现deleted file mode和 /dev/null块头里 b 的那一半是0,0。如果想让新文件出现在不加参数的git diff里又暂时不想暂存它的内容可以使用git add -N README.md。它只会在暂存区里登记「有这么一个文件」但内容是空的于是工作区里的那一份就会整个被算作新增的内容。五、只看一个文件当文件比较多时输出会很长。任何一种写法的后面都可以接上文件名这样就只查看这一个文件gitdiffHEAD config.txtgitdiff--stagedconfig.txt代码 12文件名放在最后面。不过有一种情况会出问题那就是文件名和分支名重名了。gitbranch# feature# * masterls# config.txt featuregitdifffeature# fatal: ambiguous argument feature: both revision and filename# Use -- to separate paths from revisions, like this:# git command [revision...] -- [file...]代码 13feature既是一个分支又是一个文件Git 不会替你去猜退出码是 128。报错信息里已经给出了正确的写法那就是用--把两者隔开左边写提交或者分支右边写文件。git diff -- feature查看的是 feature 这个文件而git diff feature --则是拿工作区和 feature 分支进行比较。如果习惯在文件名前面加上--就不用担心重名的问题了。不过这种写法也有另一面--后面的名字 Git 不会再核对它是否存在如果拼错了只会得到一片空白而且退出码照样是 0而如果不加--拼错的名字会被当场拦下来并报错。拼错路径退 0 是加 -- 的代价脚本里最好显式检查输出或退出码。六、比两个分支、两个提交最后是一个和上一章联系紧密的场景。feature修改了端口master修改了日志两边各自往前提交了一步。gitlog--oneline--graph--all# * 0bcc629 master: 日志级别调成 debug# | * 58e596c feature: 端口换成 8080# |/# * d7aecee 初始配置代码 14和上一章第二节是同样的形状。在合并之前如果想知道两条分支有哪些差别最直接的写法是把两个分支名交给git diffdiff --git a/config.txt b/config.txt index 2f3869a..fc0ae28 100644 --- a/config.txt b/config.txt -1,7 1,7 # 服务配置 -端口 3000 端口 8080 超时 30 -日志 debug 日志 info # 数据库 主机 localhost代码 15git diff master featurea 是 master 的最新提交b 是 feature 的最新提交。端口那两行是 feature 的改动这没有问题。但是日志那两行是反过来的输出显示的是-日志 debug和日志 info看起来好像是 feature 把日志从 debug 改回了 info可是 feature 从来没有修改过日志。这其实是 master 自己的那次改动因为 master 站在 a 那一边所以被反过来显示了一遍。这并不是出了错而是 diff 本来就是这样工作的。它只看两个端点并不关心中间的历史。在两个分支的最新提交里日志一边是 debug、一边是 info那么这就算作一处不同。如果把两个分支名的顺序调换一下所有的-和都会整个对调过来。在合并之前真正想问的问题通常是「feature 分出去以后自己修改了什么」。要回答这个问题就需要换掉 a不再拿 master 的最新提交而是拿两条分支的共同祖先来比较这个祖先就是上一章三方合并里的那个祖先。写法是使用三个点diff --git a/config.txt b/config.txt index 2559e6f..fc0ae28 100644 --- a/config.txt b/config.txt -1,5 1,5 # 服务配置 -端口 3000 端口 8080 超时 30 日志 info代码 16git diff master...feature它等价于git diff $(git merge-base master feature) feature输出里只剩下 feature 自己的那一处改动。图 3两种写法中b 都是 feature 的最新提交。用两个点时a 是 master 的最新提交用三个点时a 是共同祖先。那么两个点又是什么意思呢git diff master..feature和中间用空格隔开的写法完全一样仍然是拿两个最新提交直接比较。手册在这里特意提醒过一句diff is about comparing two endpoints, not ranges意思是 diff 比较的是两个端点而不是一段区间所以..在这里只是一个分隔符。以后在git log里还会见到..在那里它表示的是一段区间意思并不一样。比较两个提交也用同样的写法提交号可以从git log --oneline的输出中复制gitdiffd7aecee 58e596c代码 17前面的是 a后面的是 b。先写旧的、后写新的-和的含义才符合直觉。这条命令的输出和代码 16 一字不差因为d7aecee正是这两条分支的共同祖先而三个点所做的事情就是自动把这个祖先找出来。小结各种写法分别比较的是哪两份内容写法a-那一边b那一边git diff暂存区工作区git diff --staged--cachedHEAD暂存区git diff HEADHEAD工作区git diff A BA..B提交 A提交 Bgit diff A...BA、B 的共同祖先提交 B以上任一条加-- 文件只看这个文件输出中每一行的含义行意思diff --git a/… b/…这一段比的是哪个文件前一份叫 a、后一份叫 bindex 2559e6f..43a5fe0 100644两份文件内容blob的哈希不是提交号100644是普通文件--- a/…/ b/…图例-行属于 a行属于 b新建、删除时有一边是/dev/null -14,5 15,5 这一块在 a 里从第 14 行起共 5 行在 b 里从第 15 行起共 5 行空格 /-/开头上下文默认前后各 3 行/ 只在 a 里 / 只在 b 里这一章的写法虽然很多但并不需要一条一条地背下来。Git 保存的是快照而改动是比较出来的。所以 index 那一行里写的是两份文件的编号所以每一种写法都只是在指定两份快照a 在前、b 在后所以从来没有进入过 Git 的新文件谁也无法拿它来比较所以在比较两条分支时站在 a 那一边的是谁决定了输出里会不会混进对方的改动。上一章留下的第二个缺口还没有解决那就是被would be overwritten拦下时手上那一半的改动既不想提交也不想丢掉。下一章讲stash。
阅读完成 · 觉得有帮助?