通过具体例子,使用git-rebase覆盖共享历史的危险



所以我正在学习更多关于Git Rebasing的知识,我刚刚了解到,如果不使用force选项,就无法在初始推送之后推送重新基于基的分支。含义:

  1. 我切断了我的分支开发(git pull develop && git checkout -b feature/mybranch)
  2. 我在feature/mybranch上工作
  3. 我添加并提交(git add . && git commit -m "some message")
  4. 我从origin/develop
  5. 我推送git push -u origin feature/mybranch并创建PR
  6. 变更请求是PR的一部分
  7. 我在feature/mybranch中本地处理更改
  8. 再次添加并提交(git add . && git commit -m "some message")
  9. 再次,我从origin/develop
  10. 我尝试再次推送git push,以便将代码审查期间请求的更改推送到远程分支Git不会允许我这么做!如果不指定强制选项,则不会

我正在努力理解为什么。所以我询问了一下,有人告诉我:

">您不能在推送后重新建立基础以形成拉取请求,因为这会重写共享历史。共享历史是你推送的任何其他人可能已经获取的东西;你将不得不使用武力来推送一个已经推送的分支的重新基础版本,这是一个坏消息,应该警告你不要这样做,因为你可能会破坏其他人与数据的关系";

然而,作为一个新手,这个答案似乎有些神秘,对我来说意义不大,没有一个具体的例子可以凝视和理解。

试图把这种反应分解成我能理解的东西,听起来似乎这就是后续rebases+推送创建的问题:

  1. 我切断了我的分支开发(git pull develop && git checkout -b feature/mybranch)
  2. 我在feature/mybranch上工作
  3. 我添加并提交(git add . && git commit -m "some message")
  4. 我从origin/develop重设基础
  5. 我推送git push -u origin feature/mybranch并创建PR
  6. 变更请求是PR的一部分
  7. 当我在本地处理这些更改时,另一个开发人员错误地将PR合并到develop中。因此,现在develop包含其他开发人员对其他票证/PR所做的任何更改,加上我的更改,这些更改还不应该存在
  8. 因此,同时,我在feature/mybranch中本地处理这些变化
  9. 再次添加并提交(git add . && git commit -m "some message")
  10. 再次,我从origin/develop中重设基础
  11. 问题是:就像我在上面的第7步中提到的,origin/develop现在包含了作为PR的一部分推送的我的初始提交;未经授权的";通过已经包含它们的feature/mybranch提交,这导致提交历史看起来非常奇怪

我上面描述的这种情况是git在您之前已经重新设置基础并推送之后强制推送的原因吗或者我对这个回答的解释不正确?如果我的解释不正确,有人介意给我一个具体的用例(类似于我上面所做的),这样我就可以完全理解这里的固有危险吗?

有几种不同的方法可以解决这个问题。一个来自纯Git机制,另一个来自更高层次的视角。

机械

您需要使用git push --force,因为您必须说服其他Git存储库采取可能丢失数据的操作。

Git存储库主要由两个数据库组成:

  • 一个数据库包含Git的对象,这些对象是提交(带有元数据的快照)、树和Blob(实现快照)以及注释标记(通常指提交的独立实体)。

  • 另一个数据库包含Git的引用参考。(这个数据库目前是以一种非常特别的方式实现的,使用了各种文件的混合,这些文件的路径名包含ref名称组件;在这里添加一个真正的数据库是一个长期的项目。)ref只是一个名称,通常是ASCII,尽管Git在这里有相对较少的约束,UTF-8也应该可以正常工作(但请参阅"特别方式",并注意文件系统会把它搞砸),通常从refs/开始,然后将名称所在的名称空间作为其下一个组件。因此,refs/heads/保存分支名称,refs/tags/保存标记名称,CCD26保存远程跟踪名称,依此类推

主数据库中的对象存储在哈希ID名称下;散列ID是对对象的内容运行加密校验和的结果,因此一旦输入数据库,对象就永远是只读的。(Git验证在提取时再次进行校验和时,数据是否与用于查找数据的键匹配。)四种对象类型中有三种具有受约束的格式:带注释的标记、提交和树。这些可以分别引用其他对象。提交特别是指提交,通过哈希ID。

这个大球最终形成了一个有向非循环图:带注释的标记对象引用另一个对象(标记的目标)。提交是指其他较早的提交和树。树是指子树和水滴。Blob包含原始数据(主要是文件数据,但也包含符号链接的符号链接目标)。

为了进入这个DAG,我们使用引用。任何从名称直接引用的对象都是直接引用的。如果该对象引用其他对象,则这些其他对象被间接引用。

在某些情况下,Git运行git gc。这将检查主数据库中每个对象的可达性(直接或间接引用状态)。无法访问的对象将被丢弃。(这还有很多,但同样,这是一个合理的高水平开始。)

由于提交存储父散列ID,因此提交表单链(在合并提交时偶尔会有分支操作,合并提交有两个或多个父级,而不是通常的一个)。因此,引用链中的最后一次提交,指的是该链中的所有提交:

... <-F <-G <-H

这里H代表一些提交散列ID。像mainfeature/tall这样的名称可能指的是提交H。同时,提交H指的是更早的提交G,后者又指的是更早的提交F,依此类推

如果我们向这个分支添加提交,以通常的方式:

...--F--G--H   <-- main

我们得到(假设我们使用I进行下一次提交):

...--F--G--H--I   <-- main

也就是说,用于定位提交H的名称main。现在它定位提交I。通过向后移动一步,提交I达到提交H。如果我们同时添加两个提交,而不是一次只添加一个提交,那么这一切仍然有效:main将指向J,CCD_42将指向IH

这种操作——简单地提交添加到链的末尾——保证仍然引用所有先前引用的提交。测试对名称进行更新,保留所有早期的提交很容易执行:我们只从建议的提交开始,比如J,然后一跳一跳地向后搜索,看看我们是否达到了名称之前指向的old的提交。(我们可以在这里使用深度优先或广度优先搜索;Git通常使用一种广度优先搜索,但这种祖先测试随处可见,因此经过了大量优化。)

git push的工作方式正是这样的首先,发送Git会打包接收Git可能需要的新提交。接收Git将这些存储在对象数据库中——从技术上讲,存储在现代Git的隔离区中,但这里的细节并不重要。然后,发送方要求接收方更新一些ref,通常是一些分支名称。

如果更新是快进操作,即仅添加新提交是允许的。(好吧,这里允许预接收和更新挂钩有机会因为其他原因拒绝它。)如果没有,它就会被拒绝,因为如果不付出更多努力,Git就无法判断它是否会导致一些现有的提交变得无法访问

这就是机械的原因,这种推动是一个问题。

更高级别:Git缺少过时的概念

当我们运行git rebase时,我们让Git将一系列现有的提交(无论出于何种原因)复制到一系列新的改进的提交中。例如,在您的场景中,我们可以从开始

...--G--H   <-- origin/develop

I--J--K   <-- feature/mybranch, origin/feature/mybranch

由于一段时间过去了,origin中出现了新的提交。我们得到了它们(使用git fetch),现在在本地有了这个:

...--G--H--L   <-- origin/develop

I--J--K   <-- feature/mybranch, origin/feature/mybranch

我们在签出feature/mybranch之后运行git rebase origin/develop。我们的Git用一个新的改进的链取代了整个I-J-K链,该链依赖于提交K:并从中扩展

I'-J'-K'  <-- feature/mybranch
/
...--G--H--L   <-- origin/develop

I--J--K   <-- origin/feature/mybranch

如果Git有办法将现有的提交标记为"提交";由于这些新的改进版本而过时";,我们也许可以运行git push origin feature/mybranch,向他们发送I'-J'-K',并让他们检查,事实上,这三个提交应该消失,用这些新的和改进的提交替换。

实现的棘手之处在于,我们决不能丢弃I-J-K,因为任何DVCS的分布式性质意味着I-J-K;在野外";,可能会像某种病毒瘟疫一样再次困扰我们。(我们对当今世界的病毒瘟疫没有任何经验,是吗?嗯哼。)我们必须以某种方式将它们标记为过时的,而实际上根本不需要触摸它们,因为任何Git对象都无法修改。

(Mercurial的Evolve扩展做了这类事情,但在Mercurial中,提交可以。例如,所有提交都有"阶段"位,可以随时更改。通过推送或Hg等效于Git的fetch(hg拼写为pull)发布提交通常会将其从草稿阶段移动到公共版本Git.)

最新更新