TL;DR
你想要一个新的提交,它的快照来自一个旧的提交。然后,您可以从中进行 PR。使用普通的 Git 工具进行这个新的提交是很棘手的,但是绕过它很容易。不过,我会把它留到很长的部分。
长
我们需要在这里区分 拉取请求——GitHub 添加的东西,1 在 Git 所做的事情之外——和 Git 自己做的事情。一旦我们这样做了,事情就会变得更清楚一些,尽管因为这是 Git,它们可能仍然很不清楚。
Git 真的是关于提交。 Git 不是关于文件的,尽管提交 contain 文件。 Git 也不是关于分支,尽管我们(和 Git)使用分支名称来查找提交。所以 Git 就是关于 commits 的。这意味着我们需要确切地知道提交是什么以及为我们做了什么:
这个 my-parent-is-Frank,Frank's-is-Barb 的东西将提交粘合到它们的祖先链中。当我们使用普通的git merge 时,Git 使用祖先链来确定要合并的内容。不过,我们不想要 正常 在这里合并。同时,同样的父级内容是 Git 如何将提交(快照)转变为“更改”:如果我的父级提交 feedcab(不能是frank,其中有太多非十六进制字母)我提交ee1f00d,Git 比较这两个提交中的快照。什么都一样,没变。不同的文件确实发生了变化,Git 通过玩一种Spot the Difference 游戏来找出在它们中发生了什么变化并产生了一个秘诀:对@此文件的 987654328@ 版本,您将获得 ee1f00d 版本。
现在,实际上没有人使用原始提交编号来查找提交。你最近一次提交的提交号是多少?你知道吗?你在乎吗?可能不会:你只需使用main 或master 或develop 或一些名称 即可找到它。
这就是它的工作原理。假设我们有一个很小的存储库,其中只有三个提交。让我们称它们为A、B 和C(而不是使用它们真正的哈希ID,它们又大又丑,而且我们也不认识它们)。这三个提交如下所示:
A <-B <-C <--main
提交C 是我们最新的。它有一个快照(所有文件的完整副本)和元数据。它的元数据列出了早期提交 B 的原始哈希 ID:我们说 C 指向 B。同时,提交B 有一个快照和一些元数据,B 的元数据指向A。 A 有一个快照和元数据,由于A 是第一个 提交,它的元数据根本没有列出父级。它是一个孤儿,有点(所有的提交都是处女出生,有点——好吧,让我们不要再沿着这条路走下去了)。所以这就是动作停止的地方,这就是我们知道只有三个提交的方式。
但是我们find commit C by name: name main 指向 C(持有原始哈希ID C),就像 C 指向 B。
要进行新的提交,我们检查main,因此C 是我们的当前 提交。我们更改内容、添加新文件、删除旧文件等等,然后使用git add 和git commit 制作新快照。新快照会获得一个看起来随机的新哈希 ID,但我们将其命名为 D。 D 指向C:
A <-B <-C <--main
\
D
现在git commit 使用了它的巧妙技巧:它将D 的哈希ID 写入名称 main:
A--B--C--D <-- main
现在main 指向D 而不是C,现在有四个提交。
因为人们使用名称而不是数字来查找提交,我们可以通过放弃对较新提交的访问权来返回一些旧提交。我们强制一个名称,如main,指向一些较旧的提交,如C 或B,并忘记D 的存在。这就是git reset 的意义所在。不过,这可能不是您想要的,尤其是因为 Git 和 GitHub 喜欢添加新的提交,而不是将它们删除。尤其是拉取请求不会让您取消提交。
不,您想要的是创建一个 新 提交,其 快照 匹配某个旧提交。
1如果您不使用 GitHub,也许您正在使用其他一些也添加了 Pull Requests 的网站。这有点棘手,因为添加它们的每个站点都以自己的方式进行。例如,GitLab 有类似的东西,但称它们为 Merge 请求(我认为这是一个更好的名称)。
2这取决于一些 最终会失败的加密技巧。散列 ID 的大小(又大又丑)会在我们需要时将故障推开,尽管现在它有点太小了,它们很快就会变得更大更丑。
正常合并
在日常 Git 使用中,我们创建分支名称,并使用这些分支名称来添加提交。我已经展示了一个非常简单的例子。让我们稍微复杂一点。和以前一样,我们将从一个小型存储库开始:
...--G--H <-- br1 (HEAD)
我在此处添加了HEAD 符号以表明这是我们已签出的分支的名称。现在让我们添加另一个分支名称br2,它现在也选择了提交H:
...--G--H <-- br1 (HEAD), br2
由于我们通过名称 br1 使用提交 H,因此我们现在所做的任何 新 提交仅更新名称 br1。让我们做两个新的提交:
I--J <-- br1 (HEAD)
/
...--G--H <-- br2
现在让我们再次检查提交H,使用git switch br2:
I--J <-- br1
/
...--G--H <-- br2 (HEAD)
再做两次提交:
I--J <-- br1
/
...--G--H
\
K--L <-- br2 (HEAD)
我们现在可以运行git checkout br1,然后运行git merge br2,或者现在就运行git merge br1。让我们做前者:我们最终得到的 snapshot 无论哪种方式都是一样的,但是其他的事情会发生一些变化,所以我们必须选择一个。
无论哪种方式,Git 现在都必须执行 真正的合并(不是快进的假合并,而是真正的合并)。为了执行合并,Git 需要弄清楚 we 在 br1 上发生了什么变化,以及 他们(好吧,我们,但暂时没有)在 @987654389 上发生了什么变化@。这意味着 Git 必须弄清楚我们俩从哪里开始——如果我们只看图纸,就很清楚:我们都是从提交 H 开始的。我们进行了“我们的”更改并提交了(多次)并获得了J 中的快照。
H 与 J 的区别:
git diff --find-renames <hash-of-H> <hash-of-J>
告诉 Git 我们在 br1 上发生了什么变化。
类似的区别:
git diff --find-renames <hash-of-H> <hash-of-L>
告诉 Git 他们在 br2 上发生了什么变化。 (请注意,Git 在此处使用 commits:分支名称 br1 和 br2,只是用于 find 提交。Git 然后使用历史记录——如记录在每次提交的父节点中——以找到最佳共享起始点提交H。)
为了执行合并本身,Git 现在合并这两个差异列表。在我们更改了一些文件而他们没有更改的地方,Git 使用我们的更改。在他们更改了文件而我们没有更改的地方,Git 使用了他们的更改。我们都更改了 same 文件,Git 必须合并这些更改。
如果我们都进行了完全相同的更改,那很好。如果我们接触了不同的行,那也没关系——尽管这里有一个极端情况:如果我们的更改相邻,Git 会声明 合并冲突; 但如果它们完全重叠,则同样的变化,没关系)。如果一切顺利,那么在合并更改时不会发生合并冲突,Git 可以将合并后的更改应用到来自H 的快照。这会保留我们的更改并添加他们的更改,或者等效地保留他们的更改并添加我们的更改。在我们的更改完全重叠的地方,Git 只保留一份更改副本。
生成的快照——H 加上两组更改——进入我们新的合并提交。不过,这个新的合并提交有一个特别之处。而不是只有 一个普通父级,在这种情况下——在分支br1——将是J,它有两个父级:
I--J
/ \
...--G--H M <-- br1 (HEAD)
\ /
K--L <-- br2
与往常一样,Git 更新 当前分支名称 以指向新的 合并提交 M。合并现已完成。
git merge -s ours
让我们画你想要的。你从这个开始:
o--o--...--R <-- br-A
/
...--o--*
\
o--o--...--L <-- br-B (HEAD)
您想要git merge br-A,但保留快照来自L 提交br-B 的提示。
要完成您想要在原始 Git 中,您将运行:
git switch br-B
git merge -s ours br-A
Git 现在会找到合并基础 *(或者真的不打扰),然后......完全忽略 他们的 更改,并在当前分支:
o--o--...--R <-- br-A
/ \
...--o--* \
\ \
o--o--...--L---M <-- br-B (HEAD)
其中合并提交M 有L 和R 作为其两个父级,但使用提交L 作为快照。
这很简单,在原始 Git 中。但 GitHub 不会这样做!我们如何让 GitHub 提供这种结果?
我们不得不欺骗一下 GitHub
为了论证起见,假设我们要去git switch br-A——即检查提交R——然后进行一个新的提交,其快照来自提交L?也就是说,我们制作:
o--...--R--L' <-- br-A (HEAD)
/
...--o--*
\
o--o--...--L <-- br-B
提交L' 与提交L 有不同的hash ID,并且有不同的元数据——我们刚刚创建了我们的姓名、电子邮件和日期和时间等等,它的父级是R——但与提交L具有相同的快照。
如果我们让 Git 在此处进行正常合并,Git 会:
git diff --find-renames <hash-of-*> <hash-of-L>
git diff --find-renames <hash-of-*> <hash-of-L'>
获取 Git 需要组合的两个差异。 这些差异将显示完全相同的变化。
正常的合并将合并这些更改,方法是获取所有更改的一个副本。所以这正是我们想要的!最终的合并结果是:
o--...--R--L' <-- br-A
/ \
...--o--* M <-- br-B (HEAD)
\ /
o--o--...--L
我用另一种风格(中间有M)画了这个,没有什么特别的原因。 M 中的快照将匹配提交 L 和 L',分支 br-B 将在新提交处结束,对任何 文件 没有更改 ,但最后有一个新的提交。
我们可以轻松地在 Git 中提交 L',然后通过在我们的 br-A 分支上通过 L' 发送提交,在 GitHub 上提出拉取请求。 PR 将顺利合并,通过“更改”br-B 中的任何内容,只需添加新的合并提交M。所以——除了额外的L' 提交——我们得到了与git merge -s ours 在分支br-B 上运行相同的效果。
很难做到这一点
将快照L' 添加到分支br-A 的困难方法是:
git switch br-A
git rm -r . # from the top level
git restore -SW --source br-B -- .
git commit -C br-B
例如。第一步将我们置于br-A 并签出提交R。第二个——git rm -r .——从 Git 的 index / staging-area 中删除所有文件,并从我们的工作树中删除相应的文件。 git restore 将所有文件放回,但从--source br-B 或提交L 获取它们,最后一步git commit -C br-B 使用来自提交L 的消息进行新提交。 (使用-C,您可以对其进行编辑。)
这很好用,只是有点慢。为了更快,我们可以使用两种技巧中的任何一种。这是第一个,这可能是我实际使用的:
git switch br-A
git read-tree -u --reset br-B
git commit -C br-B
这消除了有利于git read-tree 的删除和恢复,这可以一举完成。 (您可以使用-m 代替--reset,但需要两个标志之一,而git read-tree 是一个我不太喜欢使用的棘手命令,所以我不记得要使用哪个:幸运的是,这里没关系。)
或者,我们可以这样做:
git switch br-B # so that we are not on br-A
git branch -f br-A $(git log --no-walk --format=%B br-B | git commit-tree -F - -p br-A br-B^{tree})
如果我没有打错字。但是,这使您没有机会编辑提交消息。你不需要直接检查br-B,你只需要确保你不是on br-A,或者你在提交后使用git merge --ff-only继续前进。
如果 GitHub 能做一个 git merge -s ours 就好了
但它不能,所以就是这样。