无论如何,从 Git 的角度来看,您认为这一切都是错误的。(你同样可以说 Git 认为这一切都是错误的——但这已经被硬编码到 Git 中并且不会改变!)
特别是,在 Git中没有文件历史记录之类的东西。在 Git 中,历史就是提交。提交是历史。这就是全部。
作为一个选项,Git 确实具有猜测文件重命名的能力。当您使用git log
标志和/或参数告诉它显示不是实际历史的东西时,这很有用,例如:向我展示一个简化的历史,其中只包括从父到子的提交,给定的路径-名称更改了内容。这就是这样git log -- setup
做的:告诉git log
走修订(提交),但只打印有关提交的信息,在父子之间,某些scripts/*
文件已被修改。
旁注:正如您所提到的,该--follow
标志git log
仅适用于单个文件名(因为实施--follow
是一个可怕的黑客攻击)并且有很多缺陷。我相信--follow
处理应该被完全抛弃并重写,但这是一项不平凡的任务(轻描淡写)。在某些时候,即使--follow
没有完全重写,它也可能开始一次只为一个目录工作;那一点甚至可能是 Git 2.17 左右;我没有对此进行测试,对于这种特殊情况,无论如何它都不是正确的答案。
该-M
标志不会真正有帮助,因为它不会更改git log
用于其限制的文件名。这是您需要了解的其余内容。
提交形成有向无环图或 DAG
每个提交都有编号,带有一个哈希 ID,每个提交存储两件事:
- 每个文件的完整快照(以提交时的形式);和
- 元数据:诸如作者和提交者、提交日志消息等信息,以及对 Git 本身至关重要的提交的父节点的哈希 ID 。
提交本身形成图的节点(或顶点),哈希 ID 存储为每个提交中的父编号,形成这些节点之间的链接(单向边或弧)。
这个 DAG 是树的泛化。树数据结构将允许分支:
I--J
/
...--G--H
\
K--L
在这里,每个大写字母代表一个提交。较新的提交出现在右侧。提交之间的边缘是单向(向后看)弧:从提交J
,我们可以向后工作I
,然后再到H
等等G
。从L
,我们向后工作到K
,然后H
像以前一样依此类推。
合并提交将此树结构转换为 DAG:
I--J
/ \
...--G--H M--N--...
\ /
K--L
合并提交M
指向两个提交,因此当 Git 在历史中向后移动时,它必须分支:以某种顺序同时访问L
and J
。
当在没有选项的情况下运行时,它从名称找到git log
的当前提示提交HEAD
开始并向后工作。在合并时,它遵循两个分支。因为它实际上一次只能处理一个提交,所以它使用优先级队列来处理这个问题。
在没有选项或提交指定参数的情况下运行git log
,并且此优先级队列仅以提交的一个条目开始HEAD
。从队列中弹出一个条目并显示提交。然后该提交的父级或父级被放入队列中,我们重复。只要以这种方式访问的每个提交只有一个父级,队列就不会再增长,并且存在队列的事实是不可见的。但是当我们点击合并提交时M
,父母双方都会进入优先级队列。现在优先级很重要:显示的下一个提交是队列前面的那个。
默认是按提交者日期顺序访问,较高的日期值(稍后提交)具有较高的优先级。所以如果 的 committer-dateL
高于 的J
,我们接下来看L
,commitK
会进入队列。否则我们将看到J
下一个,I
并将进入队列。队列继续有两个条目,因此git log
移动到最高优先级的条目。
分行名称
分支名称喜欢main
或master
只是让您(和 Git)成为图表的入口点。如果没有名称,您将不得不求助于没有人愿意使用的原始哈希 ID。分支名称只是一个可移动的指针,指向图中的某个提交节点。名称本身并不重要:重要的是每个名称都为我们提供了一个提交的哈希 ID ,这让我们可以找到其他更早的提交。
使用分支名称运行,git log
从该提交开始。使用多个分支名称运行,将通过git log
分支名称找到的每个提交放入优先级队列,然后再一次,优先级确定接下来显示哪个提交。
如何git log
处理队列并显示提交
请注意,在所有情况下,git log
都只是简单地遍历队列,插入父母,并一次显示一个提交。没有选项的默认操作是使用格式显示每个提交--pretty=medium
(尽管此默认值是可调整的)。
但是,我们可以限制 git log
显示所有提交。我们还可以改变它如何为队列提供数据以及它如何对队列进行排序(即,相对优先级是什么)。 给出路径名或路径规范参数两者都可以。 这很重要(因此是斜体)。显示提交的部分更容易描述,因为您可以立即看到它的效果。不过,在我们开始讨论之前,值得快速浏览一下git diff
.
两次提交的差异
除非我们使用git checkout
或提取整个提交,否则我们通常对提交是完整档案快照git switch
这一事实并不真正感兴趣。我们通常对两次提交之间的区别更感兴趣。
该git diff
命令可以向我们展示这一点。例如,给定两个提交E
和H
,我们可以运行git diff E H
看看有什么不同。由于 Git 的内部存储格式——文件被去重和压缩存储——Git 可以非常快速地判断 in 中的某个文件与 inE
中的同名文件完全相同H
,而无需向我们展示任何关于它的内容文件。对于不同的文件, Git 可以玩Spot the Difference游戏并告诉我们发生了什么变化。1
如果我们选择相邻的提交——即父子提交,比如G
和H
——我们可以看到在那个特定的提交中发生了什么变化H
。这对人类特别有用。提交包含所有更改的文件(因为它包含所有文件),并且日志消息告诉我们为什么进行更改的人进行更改:我们可以查看他们所做的更改是否实现了他们的目标。这只是一个例子;有很多有用的例子。关键是它git diff
可以很容易地做到这一点。
1更准确地说,输出git diff
是将左侧文件更改为右侧文件的方法。它不一定反映我们是如何做到这一点的,只是一种实现它的方式。当更改涉及 Git 无法正确理解的一些语法(例如右括号行)时,这有时很重要。Git 会建议删除错误的括号,因为其他一些括号看起来是一样的——但有时这并不完全正确。
再次记录
当git log
处理正常的非合并提交时,它同时具有原始提交和其(单个)父级的哈希 ID。这意味着它可以轻松运行git diff
。事实上,找出某些文件是否发生了变化甚至更容易,因为我们不需要让 Git 来查找差异,这是比较慢的部分:我们只需让 Git 找出是否有差异. git log
内置的也是如此。(如果我们想要的话,它也内置了完整的差异,但它可以快速完成这部分。)
对于 normal git log
,这并不重要:无论如何,它将以适当的--pretty
格式显示提交的元数据。但是当我们git log
使用路径名运行时,git log
首先会过滤掉未列出的文件——它会同时处理这个提交及其父文件——然后比较生成的文件。如果它们都相同,git log
则根本不打印提交。
这意味着:
git log -- file/in/question.ext
仅打印(使用--pretty
格式)那些有问题的文件与其 parent 中的副本不同的提交。 人们喜欢称其为“文件历史记录”,但实际上并非如此:它只是经过过滤的提交历史记录。它崩溃或分离的地方正是你现在遇到问题的地方:对于提交和提交中的同一个文件 来说,究竟意味着什么?G
H
Git 不存储重命名,但它能够通过其差异引擎即时重建重命名。当左侧提交 ( G
) 有一些在右侧 ( H
) 提交中不存在的文件 X 并且右侧提交有一些在左侧提交中不存在的文件 Y 时,Git 将,可选地,检查该文件对以查看内容是否匹配或相似。
精确匹配的查找速度非常快(由于重复数据删除技巧)。“类似”文件越来越难。Using-M
启用两种重命名检测,包括 ingit diff
和in git log
。该--follow
选项还启用了这种重命名检测——但它是一个可怕的 hack:当使用 时--follow
,git log
只有一个路径名,--follow
它的作用是启用-M
和捕获重命名并更改它正在寻找的一个文件名。因此,如果G
-vs-H
重命名了一个文件,那么在git log
查看提交G
之前,它正在寻找的一个名称是来自 的旧名称G
,而不是来自 的新名称H
。
合并:喂入队列,处理名称
合并提交与常规提交的不同之处在于合并提交有两个(或更多,但通常是两个)父级。这意味着我们需要两个差异。Git 可以做到这一点,但有多个问题。
首先,git log
通常不会打扰。如果您在git log
没有参数的情况下进行操作,或者甚至-p
打开以在每次提交时显示从父级到子级的差异,那么当git log
遇到合并提交时,它只会抛出它的虚拟手并声明这太难了。它打印日志消息,但根本不运行任何差异,然后像往常一样将两个(或所有)父级添加到优先级队列中。
其次,如果您添加路径名或路径规范,git log
将照常进行过滤。为此,它会将此提交及其所有父项都剥离到您的路径规范中的文件集。然后它会检查此提交中的所有内容是否与任何一个parent 匹配。如果是这样,它会做两件事:
因此,在合并时,我们修剪掉所有没有贡献的分支——或者更确切地说,合并父级,它们是 Git 使用的反向分支——然后我们也不打印合并提交。这种特殊的简化永远不会直接可见!
但是,如果合并提交的过滤快照与每个父级不同,git log
则将 (a) 打印提交并 (b) 跟踪所有父级。无论过滤是否显示差异,我们也可以强制git log
跟随所有父母。--full-history
请注意,如果我们--follow
打开了,重命名检测可能会弄得一团糟。假设我们在合并M
,与父母J
和L
,我们发现在M
,文件xyz.ext
被重命名为abc.ext
。但它在两个 J
和 L
中都有名称,其中一个可能是静止abc.ext
的,而另一个是xyz.ext
。如果我们访问两个分支,并且重命名发生在我们继续查看I
and之前和之间,那么当我们到达其他提交时,我们将寻找错误的名称。(在这种情况下,这工作的好坏取决于很多因素。)J
L
K
我们在这里也有几个额外的选择:
-m
导致git log
(和git diff
)将合并拆分为多个虚拟非合并提交。也就是说,我们没有考虑 commit M
,我们的合并,作为具有两个父级的单个提交,我们有git log
或git merge
假装有两个提交:M'
有父级J
和M''
有父级L
。这些现在是普通的单亲提交,可以使用单亲显示方法显示并使用单亲差异方法进行差异化。
--first-parent
导致忽略git log
合并的额外父母。这会影响它的差异——<code>M 将仅与 进行比较,假设是第一个父级——以及修订版 walk:将被视为单父提交,并且其(单独的)父级将被放入队列中。J
J
M
请注意,任何合并的第一个父级是您(或任何人)在您(或他们)进行合并时所在的分支。
结论
在 Git 中,历史只不过是提交,从分支名称(或其他起点)开始并向后工作。移动文件不会避免“混合历史”:它只是意味着提交中的快照会有所不同。分支名称根本不是历史:它们只是graph 的入口点。
Git 没有真正的文件身份。无法确定 commit 中名为 X 的文件是否与commit中名为X或YA
的文件相同或不同。它的默认假设是same-name = same-file。这可以在逐个差异的基础上进行调整。不过,这方面的工具有点粗糙。H