【问题标题】:Will rewording a commit after branch-merge affect the original branch?分支合并后重写提交会影响原始分支吗?
【发布时间】:2020-09-09 18:02:58
【问题描述】:

假设我有一个名为dev/feature 的分支,它有一些提交并被推送到远程。现在我在我的机器上将dev/feature 合并到master,但后来我意识到我想改写来自dev/feature 的提交之一。

只要我没有将master 上的新(合并)提交推送到远程,这样做是否安全?或者我可以通过改写master 上的提交来为已经拉动dev/feature 的其他人搞砸事情吗?

我想我要问的是,合并到 master 的提交是否仍然以某种方式“连接”到 dev/feature

【问题讨论】:

  • 您打算如何确切更改提交消息?
  • 如果是本地合并,没有人用过feature分支,没问题。在合并之前重置本地master,签出功能分支,修改修订,推送到远程(使用-f),这就是魔法。
  • @LasseV.Karlsen 在 IntelliJ 中使用“改写...”上下文菜单选项。由于实际提交是第二个最新的,我认为 IntelliJ 在内部使用 rebase 来执行改写。
  • @eftshift0 功能分支已经推送。合并到 master 是在本地完成的,但不是推送的。
  • 没错。如果没有人使用 feature 分支,你可以在本地修改并强制推送,没问题。您将重置的分支是 local master 以便您可以重试合并功能分支

标签: git git-merge git-branch


【解决方案1】:

作为zrrbite answered,您通常可以通过是否必须使用git push --force 来判断。但是什么时候需要这个?对于那些刚接触 Git 的人来说,其中大部分都是一个谜。了解正在发生的事情以及谁可能会受到此类事件影响的方法是了解 Git 内部如何工作的几个关键点。

我们需要知道这些事情:

  • Git 真的是关于提交。分支名称只是帮助我们——以及 Git——find 提交。

  • 每个提交都有编号。提交上的数字是一个大而难看的哈希 ID,而不仅仅是一个简单的计数;但它对于那个特定的提交是唯一的,并且每个地方的Git 都会同意那个 提交得到那个 编号。其工作方式是哈希 ID 是该提交内容的加密校验和——包括其数据(快照)和元数据(作者和提交者姓名和电子邮件、日期和时间戳、日志消息等)。

  • Git 发现提交的编号——它的哈希 ID。当您将两个 Git 相互连接时,它们会通过仅检查其哈希 ID 来确定其中一个是否有另一个新提交。

  • 因此,永远无法更改任何提交。如果您提取了一些现有的提交,并对其进行了一些修复——例如添加任务 ID,或改写提交消息,或修复您的姓名或电子邮件地址中的拼写错误——然后将新的提交写回您自己的 Git 存储库,你得到的是一个新的和不同的提交,带有一个新的唯一哈希ID

但如果提交无法更改,git commit --amendgit rebase 是如何工作的?这里的诀窍是我们通常不会发现提交他们的提交号。我们通常找到个提交——嗯,一个特定的个提交——通过一个名称,例如一个分支名称。

分支名称和提交都指向提交

每个提交的元数据存储上一个提交的哈希 ID。合并提交像往常一样存储上一次提交的哈希 ID,但至少还有一个 more 前一次提交哈希 ID。让我们暂时不用担心合并提交,只需绘制一个简单的线性提交链图即可:

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

这里,H 代表 last 提交的哈希 ID。在H 的元数据中,Git 存储了之前提交G 的哈希ID。 Git可以通过哈希ID查找H,从而找到G的哈希ID。然后Git可以通过这个哈希ID查找G,从而找到之前提交F的哈希ID。 Git 现在可以查找F,等等。

这意味着 Git 从最后一次提交返回到第一次,向后工作。我们现在需要的只是找到最后一次提交的哈希 ID 的方法。这就是分支名称的来源:

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

当我们持有哈希 ID 时,我们说持有该哈希 ID 指向该提交的任何东西。所以分支名称master指向提交HH指向GG指向F,以此类推。这是我们的向后提交链。

添加提交会更新分支名称

假设我们有:

...--G--H   <-- master (HEAD)

并且我们通过签出名称master 签出了提交H。这就是(HEAD) 的用途:如果我们有多个分支名称,我们需要知道我们使用的是哪个name

如果我们现在创建一个 new 提交,它会获得一些新的、看起来随机的、又大又丑的哈希 ID,我们将称之为 I。新提交 I 指向现有提交 H——Git 知道自动设置它,因为我们使用提交 H 就像我们创建 I 一样——一旦 git commit 完成写出 I 和因此找到了它的新哈希 ID,Git 更新 name master 以指向新的提交 I:

...--G--H--I   <-- master (HEAD)

现在Imaster 上的最后一次提交。

如果我们使用git commit --amend,Git 所做的很简单:不是让新提交I 指向现有提交H,而是git commit 让新提交I 指向H's 父母,像这样:

       H
      /
...--G--I   <-- master (HEAD)

Git 照常更新名称master。新提交 I 是链上的最后一个。但是提交H 会发生什么? 提交H 仍然存在,但我们找不到它——至少,不使用以分支名称开头的通常方法来查找最后一个 提交,然后向后工作。从I 向后工作将我们带到G,这使我们回到更早的提交:我们再也看不到H

Rebase 通过复制提交来工作

假设我们有这个:

...--G--H   <-- master
         \
          I--J   <-- dev/feature (HEAD)

我们在提交 I 的某个地方发现了一个错字,可能在源代码中,也可能在消息中。无论哪种方式,我们都需要进行 类似 I 的新提交,但不同的是:它修复了错字。

我们所做的是让 Git 复制现有的提交 I 到一些新的提交 I',之所以这样命名是因为它类似于 I,但已修复。新提交 I' 指向现有提交 H,就像您现有的 I 仍然一样:1

          I'   <-- ???
         /
...--G--H   <-- master
         \
          I--J   <-- dev/feature

已将I 复制到I',我们现在需要将Git 复制JJ 本身 很好,但 J 指向 I。我们需要一个指向I' 的副本。所以我们让 Git 也这样做:

          I'-J'  <-- ???
         /
...--G--H   <-- master
         \
          I--J   <-- dev/feature

现在只剩下一件事要做了:Git 需要将 name dev/feature 关闭 J 并使其指向 J'。我们最终得到了这个:

          I'-J'  <-- dev/feature (HEAD)
         /
...--G--H   <-- master
         \
          I--J   [abandoned]

变基就完成了。


1我在这里用问号来说明 Git 使用的技巧,这就是它所谓的 分离 HEAD 模式,无需描述它正确。 ;-)


其他存储库

当我们进行提交时,他们会获得新的哈希 ID,这是新提交所独有的。其他存储库还没有这些提交。但是现在,在我们执行 git rebase 之前,我们会将这些新提交发送到其他 Git 存储库,在我们的 Git 中,我们称之为 origin。我们运行:

git push origin dev/feature

我们的 Git 调用他们的 Git,两个 Git 进行对话。我们向他们提供提交J,因为我们的dev/feature 名称提交J。他们说:哦,嘿,我没有那个提交,请发送它。 我们现在有义务向他们提供 J 的父提交 I 而他们没有那个一个,所以我们提供提交H,但这次他们确实有它。所以我们需要发送的提交列表只包含IJ

我们的 Git 现在将打包 IJ(以及与任何新文件一样的相关内容)并将它们发送出去。 (我们的 Git 知道什么是新的,因为他们说他们有 H,我们也有,所以我们可以知道!)他们将这些暂时保存起来——他们可能决定不保留它们——而我们的 Git完成最后一个git push 步骤:其他 Git,现在我希望您创建或更新 您的 名称 dev/feature 以记住提交 J。可以吗?

如果他们说是的,那很好,完成了,我们的 Git 知道他们拿走了我们的 git push。我们的 Git 将创建或更新 我们的 origin/dev/feature 以记住这一点。

如果他们说,他们也会提供一些理由,例如“不是快进”。我们的 Git 将打印一条错误消息并包含原因,并且不会创建或更新我们的origin/dev/feature

合并

我们将跳过git merge 组合不同更改的机制,只查看最终提交;但我们也会看看 Git 会误导性地称为快进合并的东西。我说“误导”是因为这些根本不是合并。

要在我们的 Git 存储库中进行合并,我们运行:

git checkout somebranch   # choose where we want to be
git merge otherbranch     # choose which commit to merge

在某些情况下,Git 必须进行真正的合并才能做到这一点。我画了这样一个案例的标准示例:

       I--J   <-- somebranch (HEAD)
      /
...--H
      \
       K--L   <-- otherbranch

合并过程找到提交H,Git 称之为合并基础,将H 的快照与J 的快照和L 的快照进行比较,合并这两个差异,将组合差异应用于H 的快照,并进行新的提交。同样,我们完全忽略了这个组合过程,这是最困难的部分,只关注新的提交。我们使用下一个字母 M 来代表这个提交的哈希 ID。

对于新提交 M 要成为合并提交,它必须有两个父级。也就是说,它必须指向两个现有的提交。一个是和往常一样的标准提交:我们现在签出的提交,即提交J,通过名称somebranch,附加HEAD。另一个父级是我们在命令行中命名的提交:commit L,名称 otherbranch 指向它。然后Git照常更新当前分支名称,所以结果是:

       I--J
      /    \
...--H      M   <-- somebranch (HEAD)
      \    /
       K--L   <-- otherbranch

现在,从名称 somebranch 回溯,我们找到提交 M,然后我们发现 both 提交 JL,以及 both提交IK,然后提交H 等等。从名称otherbranch 回溯,我们找到L,然后是K,然后是H,以此类推。因此,新的 合并提交 仅在 somebranch 上,但通过这个新的合并提交,KL 现在在 两个分支上

当且仅当两个条件成立时,我们得到一个快进而不是合并:

  1. 我们一定有一个没有分叉的情况,比如:

    ...--H   <-- somebranch (HEAD)
          \
           I--J   <-- otherbranch
    

    这使得 Git 可以“作弊”。

  2. 我们必须告诉 Git 不要作弊,即,我们必须使用--no-ff 命令行标志。 (我们可以告诉 Git:只有在你可以作弊时才这样做,使用 --ff-only 标志。)

如果 Git 可以并且确实进行了快进,我们会得到:

...--H--I--J   <-- somebranch (HEAD), otherbranch

也就是说,Git 只是检查他们的提交,同时还将我们当前的分支名称“向前”拖动。现在,从任一名称返回,我们发现相同的提交集。没有新的合并提交。

如果你现在 rebase 和 reword,Git 必须复制一些提交

无论您如何运行git merge 以及是否有任何其他 Git 具有这些提交中的任何一个,都无法更改现有提交。所以你可能有:

...--G--H   <-- master
         \
          I--J   <-- dev/feature (HEAD)

如果您已经将这两个提交发送到 origin,并且它已经接收了它们,那么您实际上会得到这个:

...--G--H   <-- master
         \
          I--J   <-- dev/feature (HEAD), origin/dev/feature

请注意,您的 Git 仍然具有相同的提交集。你只有一个额外的name——一个remote-tracking name而不是一个分支名——它可以找到commit J。此名称仅在 您的 Git 存储库中,它会为您记住位于 origin 的其他 Git 具有 dev/feature 名称指向其副本提交J

如果您现在将IJ 复制到固定的I' 和新的J',您会得到:

          I'-J'  <-- dev/feature (HEAD)
         /
...--G--H   <-- master
         \
          I--J   <-- origin/dev/feature

您的远程跟踪名称记得他们的Git 的dev/feature 仍然选择原始提交J

如果您已经进行了合并怎么办?

如果您已经通过git checkout master; git merge --no-ff dev/feature 进行了真正的合并,您将从:

...--G--H------M   <-- master (HEAD)
         \    /
          I--J   <-- dev/feature, origin/dev/feature

如果您现在使用git checkout dev/feature 并使用git rebase -iI 复制到I',然后将J 复制到J',您会得到:

          I'-J'  <-- dev/feature (HEAD)
         /
...--G--H------M   <-- master (HEAD)
         \    /
          I--J   <-- dev/feature, origin/dev/feature

请注意,现有的合并提交 M 根本不受影响!

如果您允许 Git 执行快进而不是进行真正的合并,您将从:

...--G--H
         \
          I--J   <-- master (HEAD), dev/feature, origin/dev/feature

并将IJ 复制到新的提交中——假设你首先使用git checkout dev/feature——产生:

          I'-J'  <-- dev/feature (HEAD)
         /
...--G--H
         \
          I--J   <-- master, origin/dev/feature

请注意,现在这看起来像是 Git 进行真正合并的情况。 I'J' 的提交哈希 ID 与 IJ 的提交哈希 ID 不同,这就是 Git 真正关心的,所以如果你现在 git checkout master——它将选择旧提交 J——然后运行git merge dev/feature,你会得到:

          I'-J'  <-- dev/feature
         /    \
...--G--H      K   <-- master (HEAD)
         \    /
          I--J   <-- origin/dev/feature

如果您想摆脱旧的提交,您必须移动或丢弃它们的名称

请注意,在每种情况下,Git 如何通过 commits 确定要做什么。我们从git checkout <em>somename</em> 开始选择一个名称来附加HEAD。我们现在使用该名称,因此该名称指向的提交。下一个操作,无论它是什么——合并、变基等——finds 使用该名称和/或我们给它的其他名称提交。然后它对这些提交进行操作。

有一些 Git 命令,包括git branch只是对名称进行操作。你给他们一个名字(如果需要,还有一个提交哈希 ID),他们会尝试使用哈希 ID 对这个名字做一些事情。最常见的git branch 操作可能是创建一个指向当前提交的新名称:

...--C   <-- somename (HEAD)

运行git branch newbranch,你会得到:

...--C   <-- somename (HEAD), newname

通过省略具体的提交,你告诉git branch使用当前提交,所以Git查找名称HEAD,找到名称somename,查找名称somename并找到提交@ 987654496@。您现在可以安全地运行 git branch -d newname,它会告诉 Git 删除名称 newname

...--C   <-- somename (HEAD)

如果您有其他人没有的提交,请为其命名,如下所示:

...--H   <-- master
      \
       I   <-- experiment

然后你告诉你的 Git 删除名字experiment,你最终得到:

...--H   <-- master
      \
       I   [abandoned]

没有简单的方法找到提交 I 的哈希 ID——所以你不会再看到它了。

git push --force

如果您运行过git push,则您首先将一些提交交给了其他 Git,然后要求他们设置其中一个名称。所以他们有一个提交的名称,他们将能够找到它。如果您已将该提交替换为新的和改进的提交,则可以轻松发送新提交:

       I'  <-- branch (HEAD)
      /
...--H   <-- master
      \
       I   <-- origin/branch

但是现在当你要求 他们的 Git 设置 他们的 名称 branch 指向替换提交 I',他们会说 no em>,因为如果他们这样做,他们将无法记住原始提交 I 的哈希 ID。

他们的 Git 不会跟踪他们 得到 提交 I 的位置。他们只知道他们现在确实拥有它,如果他们遵守您的要求,将他们的名字设为branch 识别提交I',他们将无法使用该名称来查找@987654513 @还有。所以一个普通的git push 会给你一个错误。

你可以:

  • 要求他们删除他们的名字branch,如果没有--force,他们可能愿意这样做,或者
  • 使用 git push --forcegit push --force-with-lease 将 Git 的礼貌请求转化为更有力的命令。

也就是说,不要让您的 git push 以以下结尾:如果可以,请将您的名称 branch 设置为指向 ______(您的 Git 使用哈希 ID 填充空白对于I'),您只需将您的git push 结尾为:现在将您的名字branch 设置为指向___!他们可能无论如何都会拒绝,但是默认是让他们接受命令。

--force-with-lease 选项添加了安全检查。你自己的 Git 有你自己的origin/branch,记住你认为他们的branch 点在哪里。您的 git push 现在以:我认为您的 branch 已设置为 _____。如果是这样,请将其更改为_____。无论哪种方式,请告诉我。您的 Git 会从您的远程跟踪名称中填写第一个空白,然后根据您希望他们执行的操作填写第二个空白。

一些最后的笔记

我们在上面看到git rebase -i 没有复制合并提交,但是我们运行 git rebase -i 的方式,我们没有要求它复制合并提交。重要的是要意识到默认情况下,any git rebase 只是不打扰 复制合并提交。这至少部分是因为复制它们太难了。

git rebase 有一个标志,自 Git 2.18 起可用:git rebase -r 告诉 rebase 重新执行合并,以保留提交图拓扑。请注意,这实际上仍然没有复制合并:它只是再次运行git merge

没有简单的方法让git rebase 同时调整多个不同的分支名称。也就是说,给定一个提交链或git rebase -r 可以复制的子图,可能有多个名称(分支和/或标签名称)指向链或子图中的提交。新的交互式和-r 机器现在有一个明显的地方可以添加标签更新命令,但是这些命令还不存在,如果添加了这样的东西,也没有明确的方法来选择如何驱动它(但 Git 确实需要这样设施)。

【讨论】:

    【解决方案2】:

    这是一个小的“证明”,以证明修改不公开(尚未推送)的提交的安全性,就像您建议的那样。假设我们有一个提交 39e761 和两个具有相同远程:“origin”的本地存储库。

    /mnt/d/dev/git/change-history-1 [master]
    20:35 $ git ls -1
    39e7616 (HEAD -> master) Change to test
    

    我们可以看到两个存储库的远程源参考文件.git\refs\remotes\origin\master 指向以下对象:

    39e7616f7f5b7658829b795ff8e4f1d70f508be9
    

    现在让我们合并一些东西,或者在存储库 1 中本地创建一个新的提交并“改写”,或者修改它(变成bd9d987):

    /mnt/d/dev/git/change-history-1 [master]
    bd9d987 (HEAD -> master) Amend: Change to some new, local commit 
    39e7616 Change to test
    

    您可以看到,远程引用 origin 所指向的散列包含一个在 master 历史更远的散列。由于您没有修改此对象,因此您可以安全地推送并在此过程中更新每个人的远程引用(当他们获取时)。 Git 也会通知你如果你即将做一些顽皮的事情并且需要“强制”推送,因为你实际上是在“改变历史”。

    如果您正在更改历史记录,您当然需要:

    git push --force
    

    或更安全:

    git push --force-with-lease
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-12-17
      • 2014-09-17
      • 1970-01-01
      • 1970-01-01
      • 2011-05-30
      • 1970-01-01
      相关资源
      最近更新 更多