Git提交是快照,而不是差异

2020-12-19 01:56:27

Git以令人困惑而闻名。用户偶然发现误导其期望的术语和措辞。这在``重写历史记录''的命令中最为明显,例如git cherry-pick或git rebase。根据我的经验,这种混乱的根本原因是将提交解释为可以被改组的diff。但是,提交是快照,而不是差异!

我相信,如果我们拉开帷幕,看看Git如何存储您的存储库数据,Git将变得可以理解。在研究了此模型之后,我们将探索这种新的视角如何帮助我们理解git cherry-pick和git rebase等命令。

如果您想更深入,请阅读Pro Git书的Git Internals一章。

我将以v2.29.2中签出的git / git存储库为例。跟随我的命令行示例进行其他练习。

了解Git对象最重要的部分是Git通过其对象ID(简称OID)引用每个对象,从而为对象提供唯一的名称。我们将使用git rev-parse< ref>命令来发现这些OID。每个对象本质上都是纯文本文件,我们可以使用git cat-file -p< oid>检查其内容。命令。

您可能还习惯于看到以较短的十六进制字符串形式给出的OID。该字符串的长度足够长,以使存储库中只有一个对象的OID与该缩写匹配。如果我们使用简短的OID来请求对象的类型,那么我们将看到匹配的OID列表

$ git cat-file -t e0c03错误:短SHA1 e0c03模棱两可对象名称e0c03

这些类型是什么:blob,tree和commit?让我们从底部开始,然后逐步向上。

在对象模型的底部,斑点包含文件内容。要在当前版本中发现文件的OID,请运行git rev-parse HEAD:< path&gt ;.然后,使用git cat-file -p< oid>查找其内容。

$ git rev-parse HEAD:README.mdeb8115e6b04814f0c37146bbe3dbc35f3e8992e0 $ git cat-file -p eb8115e6b04814f0c37146bbe3dbc35f3e8992e0 |头-n 8 [![构建状态](https://github.com/git/git/workflows/CI/PR/badge.png)](https://github.com/git/git/actions?query = branch%3Amaster + event%3Apush)Git-快速,可扩展的分布式修订控制系统=============================== ============================= Git是一种快速,可扩展的分布式修订控制系统,具有异常丰富的命令集,可提供高级操作和完整操作访问内部。

如果我在磁盘上编辑README.md文件,则git status会通知该文件具有最近修改的时间并对其内容进行哈希处理。如果内容与HEAD:README.md上的当前OID不匹配,则git status将文件报告为``已在磁盘上修改''。这样,我们可以在HEAD上查看当前工作目录中的文件内容是否与预期的内容匹配。

请注意,斑点包含文件内容,但不包含文件名!名称来自Git的目录表示法:树。树是路径条目的有序列表,与对象类型,文件模式和该路径上对象的OID配对。子目录也表示为树,因此树可以指向其他树!

我们将使用图来可视化这些对象之间的关系。我们将方块用于斑点,将三角形用于树木。

$ git rev-parse HEAD ^ {tree} 75130889f941eceb57c6ceb95c6f28dfc83b609c $ git cat-file -p 75130889f941eceb57c6ceb95c6f28dfc83b609c |头-n 15100644 BLOB c2f5fe385af1bbc161f6c010bdcf0048ab6671ed .cirrus.yml100644 BLOB c592dda681fecfaa6bf64fb3f539eafaf4123ed8 .clang-format100644 BLOB f9d819623d832113014dd5d5366e8ee44ac9666a .editorconfig100644斑点b08a1416d86012134f823fe51443f498f4911909 .gitattributes040000树fbe854556a4ae3d5897e7b92a3eb8636bb08f031 .github100644 BLOB 6232d339247fae5fdaeffed77ae0bbe4176ab2de .gitignore100644斑点cbeebdab7a5e2c6afec338c3534930f569c90f63 .gitmodules100644斑点bde7aba756ea74c3af562874ab5c81a829e43c83 .mailmap100644斑点05f3e3f8d79117c1d32bf5e433d0fd49de93125c .travis.yml100644斑点5ba86d68459e61f87dae1332c7f2402860b4280c .tsan -suppressions100644 BLOB fc4645d5c08bd005238fc72cfa709495d8722e6a CODE_OF_CONDUCT.md100644斑点536e55524db72bd2acf175208aef4f3dfc148d42 COPYING040000树a58410edddbdd133cca6b3322bebe4fb37be93fa Documentation100755 BLOB ca6ccb49866c595c80718d167e40cfad1ee7f376 GIT-VERSION-GEN100644 BLOB 9ba33e6a141a3906eb707dd11d1af4b0f8191a55 INSTALL

树为每个子项目提供名称。树还包含诸如Unix文件许可权,对象类型(blob或树)以及每个条目的OID之类的信息。我们将输出剪切到前15个条目,但是我们可以使用grep来发现这棵树有一个README.md条目,该条目指向我们之前的Blob OID。

树木可以使用这些路径条目指向斑点和其他树木。请记住,这些关系与路径名配对,但是我们不会总是在图中显示这些名称。

树本身不知道它在存储库中的位置,这就是指向树的对象的角色。 < ref> ^ {tree}引用的树是一种特殊的树:根树。此指定基于您提交中的特殊链接。

提交是及时的快照。每个提交都包含一个指向其根树的指针,该指针表示当时的工作目录状态。提交具有与先前快照相对应的父提交列表。没有父母的提交是根提交,而有多个父母的提交是合并提交。提交还包含描述快照的元数据,例如作者和提交者(包括姓名,电子邮件地址和日期)以及提交消息。提交消息为提交作者提供了一个机会来描述有关父母的提交目的。

例如,Git存储库中v2.29.2版的提交描述了该发行版,并由Git维护者编写和提交。

$ GIT中REV-解析HEAD898f80736c75878acc02dc55672317fcc0e0a5a6 / C / _git / GIT中((v2.29.2))$ GIT中猫文件-p 898f80736c75878acc02dc55672317fcc0e0a5a6tree 75130889f941eceb57c6ceb95c6f28dfc83b609cparent a94bce62b99be35f2ee2b4c98f97c222e7dd9d82author JUNIOÇ滨野< [email protected]> 1604006649 -0700提交者Junio C Hamano< [email protected]> 1604006649 -0700Git 2.29.2签名者:Junio C Hamano< [email protected]>

通过git log在历史上再往前看一点,我们可以看到描述性更强的commit消息,讨论该commit及其父对象之间的变化。

$ git cat-file -p 16b0bb99eac5ebd02a5dcabdff2cfc390e9d92eftree d0e42501b1cf65395e91e22e74f75fc5caa0286eparent 56706dba33f5d4457395c651cf1cd033c6c03c7aauthor Jeff King< net。 1603436979 -0400Communitter Junio C Hamano& lt; [email protected]& gt; 1603466719-0700am:使用--committer-date-is-author-dateCommit e8cbe2118a修复了损坏的电子邮件(am:停止导出GIT_COMMITTER_DATE,2020-08-17)重新编写了将提交者日期设置为使用fmt_ident()而不是设置的代码一个环境变量,然后由commit_tree()处理它。但是它引入了两个错误:-我们使用作者电子邮件字符串而不是提交者电子邮件-解析提交者身份时,我们使用了错误的变量来计算电子邮件的长度,从而导致电子邮件始终为零长度字符串。这会导致我们通过base" apply"对该选项进行测试后端到现在成功。签名者:Jeff King< [email protected]& gt;签名人:Junio C Hamano& lt; [email protected]& gt;

在图表中,我们将使用圆圈来表示提交。注意到了吗?让我们来复习:

在Git中,我们大部分时间都在不参考OID的情况下浏览历史并进行更改。这是因为分支为我们关心的提交提供了指针。名称为main的分支实际上是Git中称为refs / heads / main的引用。这些文件从字面上包含引用提交的OID的十六进制字符串。在工作时,这些引用会更改其内容以指向其他提交。

这意味着分支与我们之前的Git对象明显不同。提交,树和Blob是不可变的,这意味着您无法更改其内容。如果更改内容,则会得到不同的哈希值,从而得到引用新对象的新OID!用户为分支命名以提供含义,例如Trunk或my-special-project。我们使用分支机构来跟踪和共享工作。

特殊参考HEAD指向当前分支。当我们向HEAD添加提交时,它将自动将该分支更新为新的提交。

$ git switch -c my-branch切换到新分支' my-branch' $ cat .git / refs / heads / my-branch1ec19b7757a1acb11332f06e8e812b505490afc6 $ cat .git / HEADref:refs / heads / my-branch

请注意create my-branch如何创建一个包含当前提交OID的文件(.git / refs / heads / my-branch),并且.git / HEAD文件已更新为指向此分支。现在,如果我们通过创建新的提交来更新HEAD,则分支my-branch将更新为指向该新的提交!

让我们将所有这些新术语放到一张大照片中。分支指向提交,提交指向其他提交及其根树,树指向Blob和其他树,而Blob不指向任何东西。这是一次包含我们所有对象的图:

在此图中,时间从左向右移动。提交及其父母之间的箭头从右到左。每个提交都有一个根树。 HEAD指向此处的主分支,并且main指向最近的提交。提交时的根树在下面完全展开,而其余树上的箭头指向这些对象。原因是可以从多个根树访问相同的对象!由于这些树通过其OID(其内容)引用这些对象,因此这些快照不需要相同数据的多个副本。这样,Git的对象模型形成了Merkle树。

当我们以这种方式查看对象模型时,我们可以看到为什么提交是快照:它们直接链接到该提交的预期工作目录的完整视图!

即使提交是快照,我们也经常在历史视图中或GitHub上将提交视为差异。实际上,提交消息经常引用此差异。通过比较提交及其父级的根树,从快照数据动态生成差异。 Git可以及时比较任意两个快照,而不仅仅是相邻的提交。

为了比较两个提交,首先要查看它们的根树,它们几乎总是不同的。然后,当当前树的路径具有不同的OID时,通过跟随对来对子树执行深度优先搜索。在下面的示例中,根树的docs值不同,因此我们递归到这两棵树中。这些树的M.md值不同,因此逐行比较了这两个Blob,并显示了diff。仍然在文档中,N.md相同,因此被跳过,我们弹出到根树。然后,根目录树会看到事物目录具有相同的OID以及README.md条目。

在上图中,我们注意到事物树从未被访问过,因此它的所有可访问对象都没有被访问过。这样,计算差异的成本相对于具有不同内容的路径的数量。

现在我们已经知道提交是快照,并且我们可以动态计算任何两个提交之间的差异。那为什么不是常识呢?为什么新用户会误认为提交是差异?

我最喜欢的类比之一是将提交视为具有波动/局部对偶性,有时将它们视为快照,而有时将它们视为差异。问题的关键确实在于进入了另一种实际上不是Git对象的数据:补丁。

补丁是一个文本文档,描述了如何更改现有代码库。修补程序是极端分散的组无需直接使用Git提交即可共享代码的方式。您可以在Git邮件列表上看到它们被随机排列。

补丁程序包含对更改及其价值的描述,并附有差异。这个想法是有人可以将这种推理作为理由将diff应用于他们的代码副本。

Git可以使用git format-patch将提交转换为补丁。然后可以使用git apply将补丁应用到Git存储库。在开放源代码的早期,这是共享代码的主要方式,但是大多数项目已经转移到直接通过请求请求共享Git提交。

共享补丁的最大问题是补丁丢失了父信息,而新提交的父等于您现有的HEAD。此外,由于提交时间的关系,即使您使用与以前相同的父对象,也会获得不同的提交,而且提交者也会改变!这就是Git在提交对象中同时具有“作者”和“提交者”详细信息的根本原因。

使用补丁程序的最大问题是,当您的工作目录与发件人的先前提交不匹配时,很难应用补丁程序。丢失提交历史记录会使解决冲突变得困难。

这种“四处移动补丁”的思想已被称为“四处移动提交”的几个Git命令。相反,实际上发生的是重播提交差异,从而创建新的提交。

git cherry-pick< oid>命令创建一个新的提交,其差异与< oid>相同其父是当前提交。 Git本质上遵循以下步骤:

创建一个新的提交,其根树与新的工作目录匹配,其父目录为HEAD处的提交。

Git创建新提交后,git log -1 -p HEAD的输出应与git log -1 -p< oid>的输出匹配。

重要的是要认识到我们没有将提交``移动''到当前HEAD的顶部,而是创建了一个新的提交,其差异与旧的提交匹配。

git rebase命令将自身呈现为一种移动提交以使其具有新历史记录的方式。在最基本的形式上,它实际上只是一系列git cherry-pick命令,在不同的提交之上重放差异。

最重要的是git rebase< target>将发现可从HEAD访问但不可从< target>访问的提交列表。您可以使用git log --oneline< target> .. HEAD来显示这些信息。

然后,rebase命令只需导航到< target>位置并从最早的提交开始在此提交范围上执行git cherry-pick命令。最后,我们有了一组新的提交,这些提交具有不同的OID,但与原始提交范围的差异相似。

例如,考虑到当前HEAD中的三个提交序列,因为是从目标分支分支出来的。当运行git rebase目标时,将计算公共基数P以确定提交列表A,B和C.然后将它们精选到目标顶部,以构造新的提交A&#39 ;、 B&#39 ;、和C'。

提交A&#39 ;、 B'和C'是全新的提交,它们与A,B和C共享许多信息,但它们是不同的新对象。实际上,旧的提交仍然存在于您的存储库中,直到运行垃圾回收为止。

我们甚至可以使用git range-diff命令来检查这两个提交范围的不同!我将在Git存储库中使用一些示例提交来重新建立v2.29.2标签的基础,然后稍微修改提示提交。

$ git checkout -f 8e86cf65816 $ git rebase v2.29.2 $ echo多余的行>> README.md $ git commit -a --amend -m" replaced commit message" $ git range-diff v2。 29.2 8e86cf65816 HEAD1:17e7dbbcbc = 1:2aa8919906边带:避免报告不完整的边带消息2:8e86cf6581! 2:e08fff1d8b边带:将未处理的不完整边带消息报告为错误@@元数据作者:Johannes Schindelin< [email protected]> ##提交消息##-边带:将未处理的不完整边带消息报告为错误+替换了提交消息-验证不完整的边带消息是否正确-由`recv_sideband()/`demultiplex_sideband()`正确处理-代码非常棘手:必须在循环的末尾在`recv_sideband()`中将其冲洗掉,但是实际的冲洗是通过`demultiplex_sideband()`函数完成的(因此必须知道某种方式-循环将完成)返回后)。 --为了捕获将来可能不会显示不完整边带消息的错误-让我们捕捉到该情况并报告一个错误。 -签名人:Johannes Schindelin< [email protected]> -签名人:Junio C Hamano< [email protected]> + ## README.md ## + @@ README.md:名称(根据您的心情而定):+ [Documentation / giteveryday.txt]:Documentation / giteveryday.txt + [Documentation / gitcvs-migration.txt] :Documentation / gitcvs-migration.txt + [Documentation / SubmittingPatches]:Documentation / SubmittingPatches ++额外行## pkt-line.c ## @@ pkt-line.c:int recv_sideband(const char * me,int in_stream,诠释)

请注意,生成的range-diff声明提交17e7dbbcbc和2aa8919906是``相等的'',这意味着它们将生成相同的补丁。第二对提交不同,表明提交消息已更改,并且对README.md的编辑不在原始提交中。

如果继续进行,还可以查看这两个提交集的提交历史记录如何仍然存在。新的提交将v2.29.2标记作为历史记录中的第三次提交,而旧的提交将(较早的)v2.29.0-rc0标记作为历史记录中的第三次提交。

$ git log --oneline -3 HEADe08fff1d8b2(HEAD)已替换为commit message2aa89199065边带:避免报告不完整的边带消息898f80736c7(tag:v2.29.2)Git 2.29.2 $ git log --oneline -3 8e86cf658168ee86cf65816边带:将未处理的不完整边带消息报告为bugs17e7dbbcbce边带:避免报告不完整的边带消息47ae905ffb9(tag:v2.28.0)Git 2.28

如果您仔细查看对象模型,您可能会注意到Git从不跟踪存储的对象数据中的两次提交之间的更改。您可能想知道“ Git如何知道发生了重命名?”

Git不会跟踪重命名。 Git内部没有任何数据结构可存储在提交及其父级之间发生重命名的记录。相反,Git尝试在动态差异计算期间检测重命名。重命名检测有两个阶段:精确重命名和编辑重命名。

在首先计算差异后,Git检查该差异的内部模型以发现添加或删除了哪些路径。自然地,从一个位置移动到另一个位置的文件将显示为从第一个位置删除而在第二个位置添加。 Git尝试匹配这些添加和删除以创建一组推断的重命名。

此匹配算法的第一阶段查看添加和删除的路径的OID,并查看是否有完全匹配的内容。这样的精确匹配配对在一起。

第二阶段是昂贵的部分:我们如何检测已重命名和编辑的文件? Git遍历每个添加的文件,并将该文件与每个删除的文件进行比较,以计算相似度分数(以共同行的百分比表示)。默认情况下,大于普通行的50%的行都被视为潜在的编辑重命名。算法继续比较这些对,直到找到最大匹配。

您注意到问题了吗?该算法运行A * D diff,其中A是添加数量,D是删除数量。这是二次方!为了避免超长的重命名计算,如果A + D,Git将跳过检测编辑重命名的这一部分

......