TL;DR
历史并没有消失,Git 只是没有显示它。
在 Git 中,历史是一组提交。没有文件历史记录!
当您在dir/sub 中运行类似git log dir/sub/file.ext 或就此而言,git log dir/sub 或git log . 之类的命令时,Git 将合成一个(临时)文件历史记录,通过提取来自真实历史的一些子历史——提交集。这个合成过程故意放弃一些提交。例如,它会丢弃不影响您询问的任何文件的 all 提交。但默认情况下,它通过git log 调用History Simplification 的东西减少了很多。
更长
每个提交都有一个唯一的哈希 ID。例如,您可以在 git log 输出中看到这些。哈希 ID 实际上只是提交内容的加密校验和。
每个提交都存储(哈希 ID)文件的快照——Git 将其称为 树。合并提交也是如此:合并提交与任何其他提交一样,都有一棵树。
每个提交还存储您的姓名(作者和提交者)以及电子邮件地址和时间戳,以便 Git 可以将这些显示给您。它存储一条日志消息——无论你给它什么——以便 Git 也可以显示它。
Git 在提交中存储的最后一件事——第二件事,实际上,就在 tree 之后——是 父 提交的列表,按其唯一的哈希 ID。
线性历史很容易
在处理普通的非合并提交时,查看历史记录非常简单。我们只需从 latest 提交开始,如master 之类的分支名称所标识,然后向后工作。分支名称包含最后一次提交的哈希 ID——分支的 tip——我们说分支名称指向那个提交:
... <--1234567... <--master
如果提交1234567 是master 的提示,git log 可以向您显示提交1234567 ...并且提交1234567 在其中包含正确提交的哈希ID之前 1234567.
如果我们将真实的哈希 ID 换成单个字母,为了让事情变得更简单,我们会得到这样的结果:
A <-B <-C <-D <-E <-F <-G <--master
提交G 指向提交F,后者又指向E,依此类推,直到我们到达第一个提交,提交A。这个提交没有指向任何地方——它不能,它是第一次提交;它不能有父对象——所以这是历史结束(开始?)的地方,在时间的开始。 Git 调用 A 一个 root 提交:一个没有父级的提交。
显示线性历史很容易,从时间的尽头开始,在开始时结束。 Git 只是一次挑出一个提交并显示出来。就是这样:
git log master
does:它从master 标识的一个提交开始,并显示它,然后显示一个提交的一个父级,然后显示之前的一个,依此类推。
当您让 Git 向您显示提交时,您可以(事实上,几乎总是)让 Git 将其显示为 patch,而不是快照。例如,git log --patch 就是这样做的。要将提交显示为补丁,Git 只是先查看提交的 父 的树,然后查看提交的树,然后比较两者。由于两者都是快照,因此无论从父快照到子快照的更改,都必须是使子提交的人实际所做的。
非线性历史更难
现在我们知道 Git 是反向工作的,让我们来看看更复杂的历史记录,包括包含实际合并提交的历史记录。 (我们不要因为git merge 并不总是合并这一事实而走神!)
合并提交只是一个至少有两个父级的提交。在大多数情况下,您不会看到包含三个或更多父级的提交——Git 称这些为 octopus 合并,它们不会做任何普通合并无法做到的事情,因此 octopus 合并主要是为了炫耀你的 Git-fu。 :-)
我们通常通过git checkout somebranch; git merge otherbranch 来获得合并,我们可以像这样绘制生成的提交链:
...--E--F--G------M <-- master
\ /
H--I--J <-- feature
现在,假设您运行git log master(注意:没有--patch 选项)。 Git 当然应该首先显示你提交M。但是接下来 Git 会显示哪个提交呢? J,还是G?如果显示其中之一,那之后应该显示哪一个?
Git 对这个问题有一个通用的答案:当它向您显示合并提交时,它可以将提交的 both 父级添加到“尚未显示的提交”队列中。当它向您显示一个普通的非合并提交时,它会将(单个)父级添加到同一个队列中。然后它可以遍历队列,向您展示一次提交一个,将它们的父级添加到队列中。
当历史是线性的时,队列中一次有一个提交:一个提交被删除并显示,队列现在有一个父级,您可以看到父级。
当历史有一个合并时,队列从一个提交开始,Git 将提交从队列中弹出并显示,并将两个父级都放入队列中。然后 Git 选择两个父母之一,并显示G 或J,并将F 或I 放入队列中。队列中仍然有两个提交。 Git 弹出一个并显示该提交并打开另一个。
当F 已经在队列中时,Git 最终会尝试将 F 放入队列中。 Git 避免添加两次,因此最终队列深度再次减少到一次提交,在这种情况下显示F、E、D 等等。 (这里的细节有点复杂:队列具体是一个priority queue,其优先级由附加的git log排序参数决定,所以有不同的方法可以实现。)
您可以查看与git log --graph 的连接
如果您将--graph 添加到您的git log 命令,Git 将绘制一个略显粗糙的 ASCII 艺术图形,其中包含连接子提交与其父提交的线。这非常有助于告诉您您正在查看的提交历史毕竟不是线性的,即使 git log 一次向您显示一个提交(因为它必须)。
显示合并提交
我在上面提到过-p 或--patch,git log 将通过比较父快照/树与子快照/树来显示提交中的更改。但是对于合并提交,有 两个(甚至更多)父母:没有办法向您展示 父母与孩子的比较,因为至少有两个父母。
默认情况下,git log 所做的是完全放弃。它根本不显示补丁。其他命令执行更复杂的操作,您也可以说服 git log 执行此操作,但请注意,此处默认为 git log 放弃。
当您运行git log file.ext 时,Git 将故意跳过任何非合并提交,其中差异(通过比较父与子获得)不触及file.ext。这很自然:如果你有这样的链:
A--B--C--D--E <-- master
并且您在提交A 和E 时更改(或首次创建)file.ext,您只想看到这两个提交。 Git 可以通过找出D-vs-E 的补丁并看到file.ext 更改(所以它应该显示 E)然后转到D 来做到这一点. C-vs-D 比较显示 file.ext 没有变化,所以 Git 不会显示 D,但它会将 C 放入优先级队列并继续访问C。这也没有对文件进行更改,因此 Git 最终移动到 B,它没有任何更改,Git 移动到 A。出于比较目的,A 中的所有文件始终是新文件——这是任何根提交的规则;所有文件都已添加,因此 Git 也会向您显示 A。
不过,我们刚刚看到,默认情况下 git log 不喜欢为合并计算补丁。太难了!所以git log 通常不会在这里显示合并。但是,它确实尝试简化提交图的任何部分。正如文档所说,默认模式:
如果最终结果相同,则修剪一些侧枝...
如果提交是合并,并且 [文件与] 一个父级相同,则仅遵循该父级。 ...否则,请跟随所有父母。
因此,在我们图中的 M 之类的合并提交中,Git 将进行快速检查:M 中的 file.ext 与 G 中的相同吗?如果是这样,请将G 添加到队列中。如果不是,M 和J 一样吗?如果是这样,请将J 添加到队列中。否则——即,file.ext 在M 中与在both G 和 J 中的不同——将G 和J 添加到队列中.
历史简化还有其他模式,您可以使用各种标志进行选择。这个答案已经太长了,所以我会把它们留给文档(见上面的链接)。
结论
由于 Git 执行的历史简化,您不能从 git log -- <em>path</em> 向您展示的内容中得出太多推论。如果您想查看所有内容,请考虑改为运行 git log --full-history -m -p -- <em>path</em>。 -m 选项将每个合并拆分为 git diff 目的(这与 -p 选项一起使用),--full-history 强制 Git 始终跟随所有父母。