是否有可能在不污染先前的拉取请求的情况下提交一个分支?如果没有,如何将主分支变成分支?
is it possible to commit to a fork without polluting a previous pull request? If not, how do I make the main into a branch?
Ref: 大约 9 年前的以下问题:
Pull request without forking?
背景:
我正在研究 GitHub/Git,并且正在 运行 解决问题。我进行了不懈的搜索,但没有找到任何解决此特定问题的方法 - 我发现的最接近的是上面提到的问题。
问题:
我“分叉”了一个存储库,打算做一些工作,对我自己的分叉进行更改,然后创建一个拉取请求返回到原始项目,作为对其做出贡献的一种方式。
我终于想通了,并且能够成功创建包含我提议的更改的拉取请求。
请注意,我还想做其他事情来为这个项目做出贡献,在创建拉取请求后,我继续工作并向我的本地副本做出额外的提交,包括导入一些技术文档等。
显然,无论出于何种未知原因,在我发出拉取请求后,拉取请求“拥有”我对原始回购的分叉,此后我所做的任何事情都成为那个 pull request - 它是否相关并不重要,我是否将它推送到项目的分支,我是否将它添加到 PR,或者其他什么。它看起来就像变魔术一样,只有在我 remove/revert 我自己的存储库 fork 中的更改才能被删除。
这是否意味着与该项目有关的所有工作都必须完全停止,直到该 PR 被接受 and/or 拒绝?如果是这样,其他人,尤其是在单一代码库上工作的公司,如何设法完成工作?
当然,我相信这是可能的,人们总是这样做。
我所做的研究没有披露任何似乎可以解决这个特定问题的内容,但是对不同问题的其他答案似乎暗示了这样一个事实,即一旦您分叉回购并创建拉取请求,拉取请求 似乎“拥有”您本地存储库的那个实例 - 缓解这种情况的唯一方法是:
- 分叉回购。
- 创建 repo 的整个分支并开始工作。
- 提交该分支并创建拉取请求,然后放弃该分支.
要完成额外的工作,无论在项目的哪个位置,您都必须:
- 创建一个全新的分支。
- 做你想做的任何应该与原始作品分开的工作。
- 提交到新分支,创建拉取请求,然后放弃 那个 分支。
“冲洗并重复”任何您想做的额外工作,最终得到一个分支比圣诞树还多的叉子。
这引发了几个问题:
- 这是真的吗?我理解正确吗?
- 为什么?这似乎不必要地复杂和令人费解,尤其是对于单个贡献者。
最后也是最重要的问题:
3。如何清理我的本地副本?显然我应该克隆 repo,然后创建一个分支来工作,然后创建 pull request。 (即有没有办法把我更新的“主”,变成一个分支,然后重新创建原来的 main 这样我就可以创建额外的分支来做额外的工作?)
我犹豫是否只是“破解”现有的 repo 试图解决问题,因为我不想污染原始的 pull request 或搞砸上游项目。
谢谢!
当您执行拉取请求时,您建议将您的一个分支合并到原始存储库的一个分支中。每次更新分支时,合并都会更新。
这在您修复或审查后更新时非常有用。
针对您的案例的几种解决方案,简单的关闭您的拉取请求,为您要提交的每个主题创建一个分支(每个分支基于分叉存储库的主干)。
第二种方案:
创建一个分支来让你做额外的工作
回到主要分支(或主人)
强制已经提交的分支到原始提交
并推送它
git checkout -b my_second_feature
git checkout main
git reset --hard <commit_sha>
git push -f
注意:这很长,但您确实需要了解这些内容。我已经 运行 超出了 space(字符限制为 30k)所以我将把它分成两个单独的答案。 Part 2 is here; part 3 here.
虽然“拉取请求”不是 Git 的一部分(它们特定于 GitHub 1),即使没有特别提到 GitHub,我们也可以说一些关于它们的事情。然后我们可以稍后插入 GitHub-specific 项目。那么让我们从这个开始:
Git 就是关于 提交 。虽然 Git 提交包含文件,但 Git 并不是真正关于文件,而是关于提交。而且,虽然我们使用分支名称来 find 提交,但 Git 也不是关于分支名称:它实际上只是关于提交。
这意味着您需要了解有关提交的所有信息:什么是提交、每次提交以及连续的一串提交可以为您做什么。
所以我们将从一个提交的快速概览开始,然后查看一串连续的提交。
1Bitbucket 也有“拉取请求”,但它们略有不同,GitLab 有“合并请求”,这又是 same-but-different。所有这些都建立在 Git 适当的相同基础支持上。
提交
每个 Git 提交都有编号。但是,这些数字不是简单的连续计数数字:我们没有提交#1 后跟#2 和#3 等等。相反,每个提交都会获得一个唯一的 哈希 ID——在 所有存储库中都是唯一的 ,即使它们与您的存储库根本无关2—似乎是随机的,但实际上不是。3 散列 ID 又大又丑又不可能供人类使用:计算机可以处理它们,但我们脆弱的大脑会变得混乱。因此,在下面,我将使用伪造的哈希 ID,我只使用一个大写字母来代替真实的哈希 ID。请注意,要使这些哈希 ID 起作用,提交的每个部分 都必须 完全 read-only。也就是说,一旦您进行了新的提交,该提交就会永远冻结。那个特定的哈希 ID,无论它得到什么哈希 ID,都是针对 那 提交的,任何其他提交——过去、现在或未来——都不能使用该哈希 ID。
无论如何,每个 Git 提交都会存储两件事:
提交存储每个文件的完整快照(无论如何,Git 在您或任何人创建它时都知道).为了防止存储库变得非常胖,这些文件被 (a) 压缩和 (b) de-duplicated。因此,它们以只有 Git 可以读取的格式存储,没有任何内容,甚至 Git 本身也不能覆盖。正如我们将看到的,这解决了一些问题,但也带来了一个大问题。
提交还存储一些元数据,或有关提交本身的信息。这包括,例如,进行提交的人的姓名和电子邮件地址(来自他们的 user.name
和 user.email
设置,他们可以随时更改,因此未经验证是不可靠的,但它仍然有用)。它包括一条 日志消息: 当您为自己的提交提供一条消息时,您应该写下对 为什么 您进行提交的解释。 您所做的——例如将 7 的一个实例更改为 14——是 Git 可以自行显示的内容,但是 为什么你把7换成14?是从几周到两周,还是因为 7 个小矮人都被克隆了?
在提交的元数据中,Git 为自己的目的添加了 previous 提交的原始哈希 ID 列表。这个列表通常只有一个元素长:对于 merge 提交(我们不会在这里介绍)它是 two 个元素长,并且至少任何 non-empty 存储库中的一次提交是 第一个 提交,其中没有任何先前的提交,因此此列表为空。
2这就是哈希ID必须如此大且丑陋的原因。严格来说,它们在两个永远不会 相遇 的存储库中不必是唯一的,但是 Git 不知道两个存储库是否或何时会相遇将来,如果两个 不同的 提交当时具有 相同的哈希 ID,就会发生不好的事情。我将这样的提交称为 Doppelgänger,一种预示着灾难的邪恶双胞胎。真正的灾难是——或者至少 应该是 ——只是这两个 Git 存储库的会议失败了。在一些非常旧的 Git 版本中,由于错误,确实发生了更糟糕的事情。在任何情况下,它根本不应该发生,哈希的大小有助于避免这种情况。
3当前哈希是提交中所有数据的 SHA-1 校验和,其中包括有关导致提交的提交的数据,因此它是导致该点的整个历史的校验和。 SHA-1 is no longer cryptographically secure. Though this does not break Git by itself, Git is moving to SHA-256.
提交链
鉴于上述情况,我们可以在一个很小的 three-commit 存储库中绘制三个提交,如下所示:
A <-B <-C
提交 C
是我们的第三次也是 latest-so-far 提交。它有一些 random-looking 哈希 ID,以及所有文件的快照。一个或两个文件 in C
可能与早期提交 B
中的所有文件不同,其余的与 B
中的相同因此从字面上与早期提交 B
共享。所以他们不接受任何实际的 space。修改后的文件确实需要一些 space,但它们是压缩的——有时压缩得非常厉害——可能几乎不需要任何 space。提交元数据有一点 space(顺便说一句,它也被压缩),但总的来说,这个 full-snapshot-of-every-file 可能不会占用太多 space.
同时,提交 C
包含早期提交 B
的原始哈希 ID。我们说C
指向B
。这意味着如果 Git 可以 找到 C
——我们稍后会看到它是如何做到的——Git 可以使用哈希 ID 在C
中也能找到B
。 Git 然后可以从 both 提交中提取两个快照中的所有文件,并比较它们。比较文件的结果是 diff: 说明,用于将 B
中的文件更改为 C
中的文件(反之亦然,如果你有差异按其他顺序完成)。
Git,以及像 GitHub 这样的网站,通常会 显示 提交作为差异,因为这通常比显示原始快照更有用.但是,如果您愿意,您可以轻松地获取快照:对于 Git 来说,有时 比获取差异 更容易 。 (由于 de-duplication 技巧,git diff
可以快速跳过相同的文件,但它仍然必须查看两个提交,而不仅仅是一个。所以对于哪个更容易有点混杂.)
提交 B
,作为提交,具有快照和元数据,并向后指向 still-earlier 提交 A
。但是提交 A
是 第一次 提交,因此它的元数据 不会 列出任何更早的提交。这意味着根据定义,其快照中的所有文件都是新文件。 (它们会被压缩并 de-duplicated 针对任何其他提交中的任何文件,但当时,这是 第一次 提交,所以它们只被压缩并且 de-duplicated against themselves。最后这意味着如果第一个提交包含一个大文件的 100 个相同的副本,那么在提交 A
.)
分支名称和其他名称
Git 需要一种快速的方法来找到某个链中的 last 提交。 Git 可能会迫使我们——使用 Git 的人——记下最后一次提交的哈希 ID,在本例中为 C
。我们可以将其保存在纸上、白板或其他东西上。但这很愚蠢:我们有一台 计算机 。为什么不让计算机将这些哈希 ID 保存在文件或其他东西中?事实上,为什么不让 Git 为 我们保存最近的哈希 ID ?
这正是分支名称的含义:一个保存 最新 提交的哈希 ID 的地方。 Git只需要最新的,因为最新的指向回second-latest,后者指向回一个still-earlier,以此类推。这会尽可能长时间地进行,仅当 是 没有更早的提交时才结束,这就是 Git 的工作方式:它从我们告诉它的提交开始——通常是通过分支名称—并且 向后.
让我们绘制一个简单的提交链,以散列 ID H
结尾(对于散列),并让分支名称 main
指向(包含散列 ID)H
:
...--G--H <-- main
现在让我们添加一个新名称,例如feature1
。这个名字必须指向一些现有的提交。我们可以选择 G
,或 H
,或一些更早的提交,但选择 H
似乎很自然,因为它是我们最新的:
...--G--H <-- feature, main
注意 Git 有很多种名称——不仅仅是分支名称——它们都做这种事情,即指向一个提交。所以我们可以创建一个 tag 指向提交 H
,例如:
...--G--H <-- feature, main, tag: v1.0
不过,大多数情况下,我们将只使用分支名称,这就是我现在在这里显示的全部内容。
在分支机构工作
Git 有它自己的特殊功能让我们做 工作 。正如我们之前提到的,提交快照的内容会一直冻结,并且只能由 Git 本身读取。所以我们实际上不能处理/使用提交中包含的这些文件。我们必须 Git 才能 提取 某处的文件。那个“某处”是我们的 working tre 或 work-tree.
Git还有一个很重要的东西,其中Git给出了三个名字:index,staging area,有时 缓存 。我们不会在这里讨论这一点,除了要注意当你 运行 git commit
, Git 实际上 使 来自文件的新提交 Git 的索引 / the-staging-area,而不是来自工作树中的文件。 所有 要提交的文件必须在暂存区中:这些是Git 知道的文件。提取提交会将提交的文件 复制到 暂存区以及工作树,以便它们从那里开始。
无论如何,一旦文件在您的工作树中,它们就只是您计算机上的普通文件。他们不再是 Git。它们来自 out of Git(来自提交),您可以将它们放回 into Git new commit later,但是当你做你的工作时,你在处理和使用 不在 Git 中的文件。只有 提交的 文件在 Git.
中
您像往常一样使用 working-tree 文件和 运行 git add
进行工作。 (这会将您列出的文件的工作树版本复制回索引,以便它们可以提交。在 git add
阶段,Git 进行初始压缩,de-duplication。在 Git 的索引中看到的文件是 de-duplicated 之前的文件,换句话说,这意味着索引的副本大多不带 space,除了您拥有的任何文件changed-and-added。您可以添加一个 未更改的 文件:这只是一点点浪费时间,因为 Git 会发现它是一个副本并只保留原始文件。它是浪费便宜的 计算机 时间,而不是宝贵的 人类 时间,所以随意浪费吧!但是如果你知道某些文件很大并且这也会浪费你的时间,请跳过。)
无论如何,现在您的新提交已准备就绪,您 运行 git commit
。这个:
- 收集任何必要的元数据,例如您的姓名和电子邮件地址以及当前日期和时间;
- 获取当前提交的散列 ID——您之前检查过的用于填充工作树(和 Git 的索引)的哈希 ID;
- 冻结索引的快照;和
- 将所有这些作为新提交写出,它获得一个新的、唯一的哈希 ID。
如果你有:
...--G--H <-- feature, main
就在刚才,您的 当前 提交是 H
,因此您的新提交(我们称之为 I
)指向回H
:
I
/
...--G--H
然而,Git 确实需要知道 哪个分支名称 find H
.所以这两个名字之一有一个特殊的名字 HEAD
“附加到它”。假设这个名字 过去是,现在仍然是 feature
。那么我们的绘图现在看起来是这样的:
I <-- feature (HEAD)
/
...--G--H <-- main
也就是Git用HEAD
找名字feature
,先找哈希IDH
,现在写新的哈希ID[=72] =] 进入 feature
.
这样做的效果是当前分支名称,无论它是什么,现在都指向您刚刚进行的新提交。 (请注意,I
中的快照使用了索引 / staging-area,您更新它以匹配您的工作树,因此所有三个现在都匹配,就像它们在您开始“干净”结帐或git switch
.) 如果您使用通常的 modify-files-add-and-commit 过程进行另一个新提交,您将得到:
I--J <-- feature (HEAD)
/
...--G--H <-- main
如果你现在git switch main
或git checkout main
,Git所做的是:
- 删除所有commit-
J
文件并替换为commit-H
文件;和
- 将特殊名称
HEAD
附加到 main
。
您现在拥有:
I--J <-- feature
/
...--G--H <-- main (HEAD)
你是 on branch main
,正如 git status
所说,你的工作树和临时区域是“干净的”(匹配 H
提交),你的更新文件已安全保存永远——或者只要提交本身持续——在提交 J
中,您可以使用名称 feature
.
找到它
如果愿意,您现在可以创建一个新分支,例如 feature2
,然后切换到它(使用 git branch
和 git switch
,或者组合 git switch -c
一次完成):
I--J <-- feature
/
...--G--H <-- feature2 (HEAD), main
当您在此新分支上进行新提交时,分支名称会自动更新以指向最新提交:
I--J <-- feature
/
...--G--H <-- main
\
K--L <-- feature2 (HEAD)
请注意,通过 H
并包括 H
的提交,在 Git 的术语中,在 所有三个分支 上。提交 I-J
当前 仅 feature
,提交 K-L
仅 feature2
.提交 H
是在 main
上的 最新 提交,尽管它不是有史以来的最新提交(即 您的[中的提交 L
=595=] 存储库,此时)。此外,提交 J
和 L
之间没有直接关系:它们只是可以说是表亲。他们是children的parent,parent,H
的children。
正在合并
要了解会发生什么,我们现在需要查看通常的 harder-case 合并。 Git 有一个简单案例的快捷方式,但出于各种原因(有些好,有些不太好),GitHub 尤其从不使用此快捷方式。一旦你理解了更一般的情况,简单的情况就更容易理解了。
在Git中,使用git merge
是关于组合工作。在namemain
中不画就画两个特征分支(可能还存在,只是挡住了我想画的)。我们先切换到分支feature
:
I--J <-- feature (HEAD)
/
...--G--H
\
K--L <-- feature2
我们当前的提交现在是 J
,我们现在会在我们的工作树中找到 J
的文件。我们现在 运行 git merge feature2
和 git merge
:
- 找到提交
J
(简单:只需阅读 HEAD
然后 feature
);
- 找到提交
L
(也很简单:feature2
包含正确的哈希 ID);
- 找到最佳共同起点提交。
最后一部分可能很难,尽管在这里很容易看出这是提交 H
:J
和 L
的祖父。如果 Git 现在将 H
中的 快照与 J
中的 快照进行比较,Git 将生成一个食谱,其中包含您在 feature
:
上所做的所有工作
git diff --find-renames <hash-of-H> <hash-of-J> # what "we" did
通过 运行从 H
到 L
的 second diff,Git 将生成包含所有内容的配方在 feature2
:
上完成的工作
git diff --find-renames <hash-of-H> <hash-of-J> # what "they" did
在这一点上,谁做了哪些工作并不重要:唯一重要的是“我们”更改了哪些文件,“他们”更改了哪些文件,以及我们对每个文件做了哪些更改.两个 git diff
解决了这个问题。
如果Git可以单独组合这两组变化,它可以应用组合的变化到来自 H
的 快照。不管你喜欢怎么看,这要么保留我们的更改并添加他们的更改,要么将两个更改加在一起,或者其他什么。 Git 假定最终结果是 存储在新提交中的正确快照 。
如果 Git 不能 单独组合这些更改,Git 将在合并中间停止,并带有 合并冲突。程序员现在必须得出正确的结果。我们将跳过这部分。我们假设 Git 自己得出了正确的结果。在这种情况下,git merge
会为您继续 运行 git commit
。
通常,生成的提交 M
会将提交 J
作为其 parent。我们新的 merge commit does 实际上有 J
作为 a parent — 第一个 parent— 但也有提交 L
,我们在 git merge
命令行上命名的提交,作为它的 第二个 parent,像这样:
I--J
/ \
...--G--H M <-- feature (HEAD)
\ /
K--L <-- feature2
name feature
附加到 HEAD
,照常移动指向新提交 M
。但是由于 M
向后指向 J
和 L
,提交 K-L
是现在也“在”分支feature
。这意味着通过 M
的所有提交都在 feature
,而 feature2
仍然在 L
结束并且 不 包含提交 I-J
.
如果需要,我们现在可以删除名称feature2
:只有直接找到L
才有用,如果我们不这样做觉得需要直接找L
,有需要的时候看M
的第二个parent就可以找到。如果我们现在想向 feature2
添加更多提交,我们应该保留名称并执行此操作:
I--J
/ \
...--G--H M <-- feature
\ /
K--L--N--O <-- feature2 (HEAD)
如果愿意,我们现在可以再次将 feature2
合并到 feature
中:
I--J
/ \
...--G--H M-----P <-- feature (HEAD)
\ / /
K--L--N--O <-- feature2
制作一种鸭头图片,但我们也可以重画它而没有沿着顶行的肿块:
...--G--H--I--J--M-----P <-- feature (HEAD)
\ / /
K----L--N--O <-- feature2
(不确定这个长什么样子)。
Fast-forwarding
特殊的 short-cut 案例 Git 对 git merge
适用于这种情况:
...--D--E <-- main (HEAD)
\
F--G <-- bugfix
如果我们 运行 git merge bugfix
,Git 将找到提交 E
和 G
,然后找到 merge base 的 E
和 G
:两个分支上的最佳提交。但那是提交 E
本身,即 当前提交 .
Git 可以 继续 diff E
与自身比较,发现没有变化。然后它 可以 将 E
与 G
进行比较以找到它们的变化。然后它将应用这些更改 到 E
并提出一个新的提交 H
,并给它两个 parents:
...--D--E------H <-- main (HEAD)
\ /
F--G <-- bugfix
提交 H
将是一个 merge 提交,有两个 parent,就像“真正的合并”情况一样。但显然将 E
与自身进行比较是愚蠢的,添加他们的更改只会让我们得到一个 cmmit H
其快照 完全匹配 其提交 G
中的快照。所以 Git 在这种情况下,除非我们告诉它,否则根本不会费心合并。
相反,Git 将执行它所谓的 fast-forward 合并 。这意味着 Git 直接检查提交 G
,同时向前拖动当前分支名称:
...--D--E
\
F--G <-- bugfix, main (HEAD)
现在根本没有理由在图中画出扭结:
...--D--E--F--G <-- bugfix, main (HEAD)
和删除名字bugfix
显然是足够安全的,虽然据推测main
会在后面更进一步。
要抑制 fast-forward 而不是合并,我们会运行 git merge --no-ff
。 GitHub 实际上总是这样做,因此您不会看到 fast-forward 合并发生 on GitHub;但很高兴了解他们。
何时删除名称
何时以及是否删除其他分支名称由用户决定。请注意,删除 name 不会删除 commits: 它只会让它们更难找到。但还有一件事要知道。假设我们有:
...--G--H <-- main
\
I--J <-- bugfix (HEAD)
其中提交 I
和 J
根本不起作用。你会 运行:
git switch main
git branch -d --force bugfix
放弃您修复错误的尝试。这给你留下了:
...--G--H <-- main
\
I--J ???
提交 I-J
仍然存在,但除非你写下 J
的哈希 ID,否则你可能永远无法 find 再次提交 J
。
Git 最终会检测到提交 J
是 unreachable(您无法找到它)并将删除它真实的。一旦 J
消失,提交 I
也是如此。你有一个宽限期,通常至少 30 天,在此期间 Git 不会 执行此操作,以及各种 Git 命令来帮助查找 accidentally-lost 承诺。但是,如果您不费心找到它们并重新添加名称,Git 用来跟踪“丢失”提交的“reflog 条目”最终会过期,然后——当 Git 绕过时进行维护和清洁工作——“丢失”的提交将真正从这个存储库中消失。因此,虽然提交是 read-only,但它们只是“大部分是永久性的”。只要您能找到它们,它们就会保留在您的存储库中(然后会更长一点)。
克隆、远程和多个存储库
Git 不仅仅是一个版本控制系统 (VCS);这是一个 分布式 VCS (DVCS)。 Git 进行此分发的方式是允许——或者更确切地说,强烈鼓励——存储库的许多副本存在。因此,一个 Git 存储库 是:
- a collection 提交和其他 Git objects,其中一些或全部也可能在其他存储库中;和
- 一个 collection 名称,例如分支和标签名称,可以帮助您(和 Git)找到 提交和其他内部 objects.
这些存储为 refs/
下的两个简单 key-value databases. The keys in the names database are branch names like refs/heads/main
, tag names like refs/tags/v1.2
, and many other kinds of names. Each name lives in a namespace。每个名称只存储一个哈希 ID。
objects 数据库中的键是散列 ID。此数据库中的每个 object 都有一些 Git 内部 object 类型(提交、树、blob 或带注释的标记)。提交 objects,连同支持树和 blob objects,结束存储你的文件;并且您将主要处理提交,通常不必太在意这些细节。
由于提交哈希 ID 全局 唯一,您的 某些存储库的克隆中的 object 数据库密钥是 与同一存储库的每个其他克隆中的密钥相同。当您克隆存储库时,您将获得所有或几乎所有的 提交 和支持 object。但是 names 数据库在 你的克隆 中与他们的完全分开。
这意味着存储库的克隆开始时根本没有分支名称。你运行:
git clone <url>
或:
git clone -b <branch> <url>
并且您的 Git 软件会创建一个新的 totally-empty Git 存储库以启动。您的 Git 软件使用您的 Git 存储库(我喜欢将其缩短为“您的 Git”)调用 他们的 Git软件并将其指向 他们的 Git 存储库(“他们的 Git”)。他们的 Git 列出了他们所有的分支和标签以及其他名称和哈希 ID,然后你的 Git 要求它想要复制的 objects(通常,他们全部)。对于您将要获得的每个提交,他们的 Git 有义务提供该提交的所有 parent,以及 parent 的 parent,等等.所以你最终将每个提交复制到你的 Git.
现在您拥有所有 提交 (并支持 object),您的 Git 需要他们的每一个 分支名称 和 重命名 它们。这个重命名过程利用了“远程”的概念。
一个remote,在Git中,只是一个短名称,至少存储了一个URL(以后你可以让它存储各种额外的功能). URL 是您在 git clone
中键入的那个,第一个“远程”的 名称 始终是 origin
. 4 所以 origin
从现在开始意味着 我从 克隆的 URL,除非你改变了一些东西。
Git 使用这个名称——origin
字符串——为 their 分支组成 new names名字。他们的main
变成了你的origin/main
;他们的 debug
变成了你的 origin/debug
;如果他们有 feature/tall
,你会得到 origin/feature/tall
;等等。这些名称实际上并不是 branch 名称;我喜欢称它们为 remote-tracking names.5 它们的功能是记住,对于您的 Git 存储库,什么 他们的 分支名称,什么提交 每个选择的名称,你的 Git 上次从他们的 [=824] 获得更新=].
重命名完成后,您的 Git 已经为他们拥有的每个分支名称创建了 remote-tracking 名称。您拥有他们所有的提交,并且可以找到所有这些,因为您的 remote-tracking 名称拥有与其分支名称相同的哈希 ID,他们正在使用它们来查找他们的提交。
现在,在您的 git clone
完成并 returns 控制您开始工作之前不久,您的 Git:
- 根据您提供的
-b
参数在您的存储库中创建一个新的 分支名称 :如果您说 -b bugfix
,您的 Git找到与他们的 bugfix
相对应的 origin/bugfix
并创建 你自己的 bugfix
,指向相同的提交。
- 签出(切换到)这个新分支。
所以现在你的克隆中有 一个 分支,匹配他们的一个分支。如果您不使用 -b
,您的 Git 会询问他们的 Git 他们推荐什么名字。通常的标准推荐是他们的主要分支(现在通常是main
;过去是master
)。
获得克隆后,您可以使用 git remote add
添加 更多 个遥控器。这需要一个名称 for 遥控器,以及一个 URL;它设置了遥控器,但还没有 运行 git fetch
。现在是时候讨论获取和推送了;查看其他答案。
4您可以选择其他名称,但这样做几乎没有任何意义。使用 origin
作为“主遥控器”的名称。您可以随时重命名遥控器,因此即使您不打算保留开头的 URL,让 git clone
默认为 origin
也可以正常工作。
5Git 调用它们 remote-tracking 分支名称,击败可怜的重载词 branch
从血腥、畸形的野兽到 barely-recognizable-splotch。说真的,把branch这个词放在这里,没有任何帮助。
第 2 部分—参见
git fetch
到 运行 git fetch
,你选择一个遥控器并调用它作为 git fetch <em>remote</em>
.如果您省略远程名称,Git 会从某个地方选择一个远程,或者尝试使用默认名称 origin
,具体取决于很多配置项。如果你只有 有 一个名为 origin
的单一标准遥控器,运行ning git fetch
没有额外的参数是好的:你没有别的意思无论如何。
fetch 的作用是:
- 调用任何 Git 软件回答存储的 URL;
- 让他们列出所有名称(分支、标签等)和相应的哈希 ID;和
- 从他们那里获取他们拥有而您没有的任何提交。
请注意,这与我们对 git clone
执行的操作相同,只是现在不是“获取他们所有的提交”,而是“获取他们拥有但我们没有的提交”。由于提交具有全局唯一的 ID,我们可以很容易地告诉我们(比如说)提交 a123456
因为我们有一些 ID 为 a123456
的对象,并且我们缺少——因此需要——b789abc
因为我们没有这样的ID。获得他们的 new-to-us 提交后,我们的 Git 现在更新相应的 remote-tracking 名称。
换句话说,git fetch
与 git clone
做的事情几乎相同,只是 我们的 Git 存储库已经存在,我们可能得到的数据要少得多,而且我们没有最后的“创建分支并检查它”的步骤。由于我们可以有多个 remote,我们可以 运行:
git fetch origin
并更新我们所有的 origin/*
名称,然后 运行:
git fetch upstream
并更新我们所有的 upstream/*
名称,如果我们使用 git remote add
添加一个名为 upstream
的 second 遥控器。
要一次更新所有我们的遥控器,我们可以使用git fetch --all
或git remote update
;两者本质上做同样的事情。请注意,--all
到 git fetch
表示 所有遥控器 ,而不是 所有分支: 我们已经获得了所有分支。 (我提到这个是因为人们一直认为 --all
意味着 所有分支 而它从来没有。)
如果我们愿意,我们可以限制我们的git fetch
像这样:
git fetch origin main
这让我们的 Git 像往常一样打电话给他们的 Git 并列出事情,但是这次,我们的 Git 只麻烦 询问 他们在 main
上的任何 new-to-us 提交。当一切都完成后,我们的 Git 然后更新我们的 origin/main
(我们知道 origin
的 main
现在在哪里,所以我们相应的 remote-tracking 名称,即, origin/main
,可以更新)。如果他们在 dev
上有新的提交,我们不会得到它们,我们也不会更新我们的 origin/dev
;我们的Git被告知只需要main
。
在一些(罕见的)设置中,这种事情可以节省大量数据传输。 Git 因此提供了一种叫做 single-branch 克隆 的东西,其中 git fetch
通过 default 来做到这一点。这是人们尝试使用 --all
的地方(但它不起作用):要从 single-branch 克隆中获取其他分支,您必须添加它们——参见 the git remote
documentation——或使用显式 refspec。不过,出于 space 的原因,我们不会在这里适当地介绍 refspecs。
由于您将有 两个 遥控器,一个用于您的 GitHub 分支,另一个用于您分支的 GitHub 存储库,您将想要 运行 git fetch
两次,或者偶尔使用 git remote update
或 git fetch --all
。除此之外 - 并且拥有 upstream/*
,如果你像大多数人一样调用第二个远程 upstream
- 你的存储库仍然像任何其他存储库一样。
git push
git push
命令与 git fetch
非常相似,有几个主要区别:
首先当然是git push
表示发送东西。您使用 git fetch
来 获取新提交 (和其他内部对象) 来自 一些其他 Git (一些其他软件工作与其他一些存储库).. 你使用 git push
来 发送 新的提交,通常是你所做的——但它们 可以是 你的例如刚从 upstream
到其他 Git.
其次,一旦您发送了这些提交,您通常会要求 其他 Git 设置 它的分支名称之一。在推送方面没有这样的东西,比如 remote-tracking 名称。
最后一部分意味着您必须权限才能写入存储库。 Git 本身根本没有真正的访问控制,但大多数网络托管网站,包括 GitHub,都添加了他们的访问控制。 GitHub 在这里特别添加了很多花哨的控件。您 and/or 其他人是否使用它们取决于您和他们。
要执行 git push
,您通常 运行 一个简单的:
git push <remote> <name>
这表示您希望 Git 查看您名为 name
的分支上的提交,找到哪些提交origin
的另一个 Git 的新手,将 发送给 Git 的 ,然后礼貌地询问他们是否愿意,拜托,设置 他们的 名称 name
指向与您的 name
[=388= 相同的提交]指向。
换句话说,您要求他们创建或更新与您的分支同名的分支。一般来说,他们会接受这个 当且仅当 这只是 添加 到他们的分支(当然你有权限)。也就是说,当我们有:
...--G--H <-- main (HEAD), origin/main
因为我们的 main
与来源的 main
匹配,我们添加了一两个新的提交:
I--J <-- main (HEAD)
/
...--G--H <-- origin/main
我们 运行 git push origin main
,我们的 Git 调用他们的 Git,向他们发送提交 I-J
,并要求他们设置他们的 main
指向 J
.
如果他们的 main
仍然指向 H
——或者不知何故,指向 G
因为有人让他们 drop H
—他们会很高兴地接受我们的请求来添加到他们的 main
。由于我们的 Git 看到他们接受了,我们最终得到:
...--G--H--I--J <-- main, origin/main
知道 origin
的 main
现在指向提交 J
。
但是假设其他人出现并添加了一些提交 K
到 他们的 main
:
...--G--H--K <-- main [over on origin]
我们的请求现在将要求他们放弃他们的提交K
,这将给他们留下:
...--G--H--I--J <-- main
\
K ???
他们会说 no,您将收到的错误消息是 not a fast-forward(记住那些来自合并? 这是同一个想法)。
您可以使用 --force
或 --force-with-lease
,尝试让他们接受更改,丢失他们的新提交,但是 通常 这是错误的要做的事。然而,对于 GitHub 的使用,有时这是 正确 在你的分叉上做的事情!我们稍后再谈这个。
还有一种方法可以使用 git push
删除名称。其实有好几个,不过最清楚的大概就是git push --delete <em>remote branch</em>
: git push --delete origin foobranch
would在 origin
上丢弃 foobranch
。这对您的笔记本电脑存储库没有影响。
Git集线器“分支”
我们现在有足够的背景来定义 GitHub 的 FORK 按钮是如何工作的。您选择一些不属于您自己的现有存储库并单击它,GitHub 将在 GitHub 上创建一个新存储库 是你自己的。这个 GitHub“fork”是一种克隆,但有几个添加的功能和一个变化。
既然您知道 git clone
复制 没有 分支,那么变化就很明显了。当您使用 GitHub 的 fork 按钮时,它会复制 all 个分支。您的新克隆与原始克隆具有相同的提交和分支集,而常规 clone-to-your-laptop 获得所有提交,但分支的 none,并且然后创建一个 new 分支,使 accidentally-on-purpose 与 origin
的分支之一完全匹配。 fork 按钮使 all 您的 fork 中的分支名称与所有其他存储库的分支完全匹配。
添加的功能包括提出 拉取请求 的想法,我们稍后会回过头来。在 GitHub 方面——你不可见,但对 GitHub 本身非常重要——添加的功能包括不使用任何 space 来保存提交:你的 fork 只是 re-uses 来自原始的提交。没有提交可以改变,所以这很好;唯一可能发生的问题是如果要删除提交,因此 GitHub 只是安排永远不会删除提交。1
不过,一旦你做了分叉,那些分支名称,在你分叉的 GitHub 上,不要再更新,直到并且除非 你去做吧。您可以从 GitHub 的 Web 界面执行一些操作(例如,您可以删除分支名称),或者您可以像往常一样在笔记本电脑上使用 git push
。
因此,一旦您 有了叉子,您就会想将该叉子克隆到您的笔记本电脑上,然后,在 笔记本电脑上, 添加第二个 URL 到你分叉的存储库。标准的 GitHub 命名方式 URL 是使用远程名称 upstream
。我个人不喜欢这个名字,因为 upstream 这个词在 Git,2 中已经有多种含义,但是 运行 ,如果你将 ssh://github.com/them/repo.git
分叉到 ssh://github.com/you/repo.git
,你会 运行:
git clone ssh://github.com/you/repo.git
cd repo
git remote add upstream ssh://github.com/them/repo.git
git fetch upstream
您现在有 origin/*
和 upstream/*
个名字。我们现在来了解其中一个方便的技巧。
1这意味着如果有人不小心将密码放在 GitHub 上,它可能永远存在,即使他们很快 force-push 隐藏它。 GitHub 支持可以“真正”清除提交,但总的来说,始终考虑任何意外暴露的秘密,即使是一瞬间,也会永远受到损害。
2所以,至少比branch这个词好。
小窍门:更新你的叉子
拥有 运行 git fetch upstream
或 git remote update
以便您的 upstream/*
名称全部更新,您可能希望让您自己的 fork 拥有他们的所有更新相同的 分支 名称。这意味着对于每个 upstream/whatever
,您想要 运行:
git push origin upstream/whatever:whatever
这种 git push
使用 refspec,我们在左侧放置一个“来源”名称,然后是一个冒号,然后是“目的地”右边的名字。 Git 将从给定的源(我们的本地 upstream/whatever
remote-tracking 名称)中提取提交,但是当它们到达目的地(origin
)时,要求目的地进行设置他们的destination-side名字(他们的whatever
)。
您可以使用循环来完成此操作,但还有更短的方法。请注意,您可能需要保护 *
字符免受 shell 的影响,具体取决于您的特定命令行解释器:
git push origin "refs/remotes/upstream/*:refs/heads/*"
我假设您需要双引号才能获得正确的保护。如果您不能使用双引号,请使用 是 所需的任何引号机制(可能根本就是 none)。
这里,我们把名字全拼出来了:remote-tracking名字在refs/remotes/
name-space,而分支名字在refs/heads/
。 Git 匹配两颗星并在此处执行 每个 分支的常规 (non-forced) git push
。
您可以制作一个 Git 别名来执行此操作 git push
,以避免必须键入长命令并避免引用 refspec(一个简单的 Git 别名可以不通过shell):
[alias]
up2hub = push origin refs/remotes/upstream/*:refs/heads/*
请注意,这会嵌入名称 upstream
和 origin
,但现在您可以 运行:
git up2hub
在 git fetch upstream
成功后,更新您的 GitHub 分支。
拉取请求
现在我们终于到了问题的核心:拉取请求是如何工作的。 当您使用 CREATE PULL REQUEST 按钮时GitHub,你选择两个东西,尽管 GitHub 会为你默认其中一个:
- 你的分支中的一个分支名称;和
- other GitHub 存储库3 中的一个“基本分支”,您希望对其进行 PR。
GitHub 现在将 运行 进行“测试合并”,他们尝试对 您的 [=] 上的一组提交进行常规 git merge
=388=] 分支,通过合并请求导入到他们的存储库,到 他们的 分支的当前提示提交。也就是说,GitHub 获取你在 fork 中的每个提交,而他们在他们的存储库中根本没有,然后 将这些提交复制到他们的存储库 。 4
他们现在可以在 pull-request 的全名下找到您的提交,在 GitHub 上是 refs/pull/<em>人数</em>/头
。测试合并,如果它有效,将创建一个新的提交并使其可以通过 refs/pull/<em>number</em>/merge
访问。如果它因合并冲突而失败,PR 仍然会生成,只是没有refs/pull/<em>number</em> /merge
名称。
3您可以向 共享访问 存储库发出拉取请求,您和其他人都将推送到单个存储库,而不是每个到他们自己的叉子。在那种情况下,您会选择此存储库本身作为“其他”Git 存储库。但这只是一个特例,“其他”存储库是“这个”。
4这一切都是虚拟发生的,为了节省磁盘 space:他们的存储库有一个 link 回到你的,所以没有实际的复制,就像当你分叉他们的 repo 时,你没有真的 复制他们的提交。同样,Git 开始使用每个提交都有一个唯一的哈希 ID 的事实:您的提交和您的哈希 ID 保证与所有提交具有不同的哈希 ID。因此,当他们的 Git 软件试图找到提交 fee1cab
或任何哈希 ID,但找不到时,他们可以在您连接的分支中查看,它就在那里。你的叉子指回他们的回购,他们的回购回指回你的叉子,在一种乱伦循环中。
那么这一切意味着什么?
好吧,我们来看一个经典的例子。您 fork 一些存储库,并将其克隆到您的笔记本电脑并创建一个新分支:
...--G--H <-- main, my-feature-1, origin/main
您在 my-feature-1
分支上做了几个新的提交:
I--J <-- my-feature-1 (HEAD)
/
...--G--H <-- main, origin/main
您将这些提交发送到您的 GitHub 分支:
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
然后单击按钮进行 PR,在 他们的 分叉中,他们现在拥有:
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
提交 M
是 GitHub 的“测试合并”,有效; commits K-L
是他们在你忙于你的叉子和你的笔记本电脑时所做的新提交。
如果你现在继续制作:
I--J <-- my-feature-1, my-feature-2 (HEAD)
/
...--G--H <-- main, origin/main
在你的笔记本电脑上,然后进行两次 more 提交,你得到:
N--O <-- my-feature-2 (HEAD)
/
I--J <-- my-feature-1
/
...--G--H <-- main, origin/main
你可以 git push
这些到你的 GitHub 分支,这样它就有:
N--O <-- my-feature-2 [on your fork]
/
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
如果您现在使用此 my-feature-2
name 在 GitHub 上制作 PR ,则该 PR包含尚未在 main
中的提交 I-J-N-O
,因为他们尚未决定如何处理 PR#123:
N--O <-- refs/pull/124/head
/
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
(如果他们能够将 O
与 L
合并,或许还有一个测试合并)。
如果那不是你想要的,你 应该放在 GitHub 上的是:
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
\
N--O <-- my-feature-2 [on your fork]
你的 my-feature-2
现在只包含 两个 提交不在他们的 main
中,所以 PR#124 使他们的存储库看起来像这样:
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
\ \
\ P <-- refs/pull/123/merge
\ /
N--O <-- refs/pull/124/head
第 3 部分:您的下一个绊脚石:变基
(; )
当他们(无论他们是谁)开始审查您的 PR 时,他们可能会:
- 接受原样;
- 请你修复里面的东西;
- 接受他们所做的改变;或
- 完全拒绝。
最后一个在这里不需要更多讨论,但其他三个需要。
如果他们“按原样”接受你的提交,他们可以选择使用三个不同的“接受这个 PR”MERGE 按钮, 在 Git 中心:
- 合并。这个很简单。
- 变基并合并。这个没那么简单。
- 压缩并合并。这个需要理解Git的“squash merge”,它根本不是合并。
如果他们自己进行任何更改,那很像 REBASE AND MERGE 的情况,正如我们将要看到的那样。如果他们想要 you 进行更改,you 将需要在您的笔记本电脑上使用 git rebase
,之后您将需要使用 git push --force
或 git push --force-with-lease
更新您的 GitHub 存储库。5
5从技术上讲,您可以删除并 re-create 您的分支,而不是 force-pushing。不过,我认为这会破坏现有的 PR(我还没有尝试过)。在任何情况下,force-push 选项都是人们在实践中使用的选项。
Rebase 是关于复制 提交
我们使用 git rebase
将现有的提交复制到我们使用 而不是 原件.
在我们看那个之前,让我们看一下复制一个提交的命令。正如我们所知,任何提交都不能 更改 ,但我们始终可以提取提交,或将其转换为差异,或其他任何方式。我们可以使用这个 属性 来获取一些现有的提交,将其转化为更改,然后再次应用这些更改,或应用到其他地方。 Git 调用此操作 cherry-picking,并使用命令 git cherry-pick
来执行此操作。
通常,出于某种原因,我们可能会使用 git cherry-pick
作为获取提交的 one-off 副本的快速方法。例如,也许有人有一个好主意或错误修复,我们现在需要 在 我们的 分支上,我们会想办法来处理这将在以后造成的混乱。我们在我们的分支:
...--J--K <-- feature (HEAD)
与此同时,在他们的分支上,他们修复了一些困扰我们的讨厌的小错误:
...--P--C--R--S <-- theirs
(我们将在 P
之后调用 fixing-commit,“parent” 提交,C
,“child”,此处,而不是我通常使用的字母 Q
)。我们运行:
git cherry-pick <hash-of-C>
告诉Git:去弄清楚whoever-it-is在提交C
中做了什么,P
的child,通过比较 P
和 C
看看有什么变化。然后在我的提交 K
上进行 相同的 更改,并从中进行新的提交。 结果图如下所示:
...--J--K--C' <-- feature (HEAD)
Git 会将他们列为提交 C'
的作者,以及 re-use 他们的提交信息;我们将被列为 C'
的 提交者 。 (大多数时候,作者和提交者是同一个人,但不一定;像这样的副本通常将他们保留为作者,并保留他们的提交 date-and-time 邮票。)
Git 实际实现此“复制他们的更改”的方式是 Git 必须弄清楚要触摸哪些文件以及这些行去了哪里。为此,Git 从 P
到 C
进行比较,以查看 他们 做了什么,然后从 [=39 进行第二次比较=] 到 K
看看 我们 做了什么。回想一下 git merge
是如何工作的:这是合并的核心: 合并两个差异并将合并后的差异应用于合并基础 。 Git 只是强制提交“基础”P
,不管其他。我们的提交是提交 K
,他们的提交是提交 C
,仅此而已——除了当 Git 提交合并时,它使它成为一个普通的 single-parent 犯罪。提交 C'
仅指回 K
,而不是 P
或 C
.
最终结果,如果一切顺利——如果没有合并冲突——我们有来自提交 C
的 更改,但应用在这里,在提交 K
。因此,新提交 C'
是 C
的 副本,因为它:
- 有 一些 与
C
相同的元数据:特别是日志消息和作者身份;和
- 具有相同的 差异 ,除了在我们必须解决合并冲突时添加或删除的任何内容。
有了git rebase
,我们可以:
- 采取一系列现有的提交并简化移动他们,或者
- 使用 interactive rebase,做各种额外的摆弄。
Interactive rebase 是一个很大的 blog-post 或单独的一篇文章,我们不会在这里详细介绍。我们只看一个简单的 rebase-to-move 作业。我们开始于,例如:
I--J <-- feature (HEAD), origin/feature
/
...--G--H--K--L <-- upstream/main
假设,在这一点上,我们喜欢一切关于I-J
除了他们不会追赶K-L
。让我们再添加一个问题:存在 合并冲突 ,例如,因为我们在 J
中接触的其中一条线与我们(或他们)在 [=] 中接触的其中一条线相邻70=]。这个合并冲突,或者 Git 认为是冲突的事情,很容易解决,但是 Git 不会做,所以我们必须自己做。
此时,我们只是运行:
git rebase upstream/main
请注意,我们不必在这里使用 分支 名称,我们甚至不必更新 origin
:我们可以在任何提交上变基我们已经为该提交使用任何名称。 name upstream/main
找到 commit L
,这就是我们要复制 I
和 J
到 come-after 的 commit,所以这就是我们给 git rebase
这里.
Git 将在内部保存提交 I
和 J
的原始哈希 ID,它们是要复制的提交。 (Git 如何知道这一点——以及我们如何改变被复制的内容,到哪里——在别处得到回答,但请注意我们的 当前分支名称 指向 J
并且提交 I-J
是唯一可以从当前分支 仅 访问的提交。)然后,Git 将切换到提交 L
——那个我们命名了——在 Git 所谓的 分离 HEAD 模式中。这里根本没有当前分支: HEAD
直接指向当前提交。所以现在我们有了这个:
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main, HEAD
Git 现在做一个 cherry-pick 复制 I
。这行得通,并且 Git 进行了新的提交 I'
:
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main
\
I' <-- HEAD
Git 现在尝试第二次 cherry-pick 复制 J
。这个因 合并冲突 而失败。我们通过在编辑器中打开冲突文件,将 正确的合并结果 放入文件,并将文件写回我们的工作树来解决冲突,然后 运行ning git add
在那个文件上。然后我们使用:
git rebase --continue
使 Git 恢复 committing-and-copying-and-so-on。 Git 为 J'
提交(副本,我们的决议到位,如 git add
-ed):
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main
\
I'-J' <-- HEAD
如果有更多提交要复制,Git 会尝试 cherry-picking 下一个。然而,事实上,copy 已经没有什么可做的了,所以 git rebase
进行最后的操作:
- rebase 将名称
feature
拉到这里,无论 HEAD
在哪里;和
- 变基 re-attaches
HEAD
所以我们现在有:
I--J origin/feature
/
...--G--H--K--L <-- upstream/main
\
I'-J' <-- feature (HEAD)
原来的I-J
提交仍然存在。如果您记下了它们的哈希 ID(或者它们仍然在您的屏幕上),您仍然可以看到它们。使用名称 origin/feature
,您仍然可以看到它们。但是,如果您使用 name feature
查找提交,您将找到新的副本 而不是 原件。
更新拉取请求
要更新我们现有的拉取请求(可能是 PR#125,feature
到我们分叉的存储库中的分支名称),我们只需告诉 GitHub 接受这两个新提交。一个普通的:
git push origin feature
不会工作,因为 Git 在 GitHub 会 object: 嘿,如果我更新我的 feature
,我会失去这两个非常有价值的 I-J
提交!不是快进! 拒绝!我们必须强制它更新,使用新的替换I'-J'
。 6 所以我们 运行:
git push --force-with-lease origin feature
或更短的 --force
变体。 (--with-lease
增加了一些错误检查,这是个好主意,但仍然感觉很新奇,至少对我来说打字很笨拙。这是 15+ 使用 Git 的缺点之一年。)我们告诉 GitHub 我们是认真的,他们接受了新的提交。
因为有一个公开的 PR 引用 name feature
,GitHub 将在此时再次尝试合并。他们上次尝试此合并时,与提交 L
发生合并冲突。这一次,我们 将 添加到他们的提交 L
,因此没有合并冲突,并且 PR 可能会被接受 as-is.
如果我们需要进行额外的更改,我们可以使用花哨的交互式 rebase,或者做额外的提交然后压缩,或者任何我们喜欢的。
6如果Git知道这些是进化的替代品就好了。
挤压
Git 提供它拼写的内容 git merge --squash
。这不是合并,就像 fast-forwarding 不是合并一样:没有最终的 merge commit。但它 是 合并,就像 git merge
做一对差异和组合工作一样:这是 combining-work 部分。
给定:
...--G--H--I--J <-- br1 (HEAD)
\
K--L <-- br2
如果我们 运行 git merge --squash br2
, Git 将:
- 像往常一样找到合并基地
H
; * 像往常一样做两个差异;
- 合并差异,如果存在合并冲突则停止并让我们修复混乱;或
- 如果没有冲突,无论如何都停止。
我相信,这个“无论如何停止”只是最初实现的一个意外——git merge
有一个 --no-commit
标志来 使 它停止,它应该与 --squash
分开,但 --squash
始终打开 --no-commit
。但是,无论如何,我们现在必须通过 运行ning git commit
完成操作,这会像往常一样提交 Git 的索引和您的工作树中的内容,并且 不进行合并提交。不是合并 M
和 两个 parent,我们得到一个简单的普通提交 S
——“挤压合并”——一个 parent 像往常一样:
...--G--H--I--J--S <-- br1 (HEAD)
\
K--L <-- br2
新提交 S
中的 快照 与我们 将 拥有的快照相同,如果我们做了常规合并,但 S
不会 link 回到 L
,现在唯一 有用的 与名称 br1
相关就是 删除 它。7 这两个提交 K-L
实际上已经合并,或者 squashed,进入单个提交 S
。提交 S
与 K-L
具有相同的 效果 ,因此 K-L
现在无用,应该被遗忘。
7也可以这样做,但这超出了这个答案的范围。
squash-merge 如何与 GitHub PR
相关
如果上游有人接受你的 PR 并且 squash-merge 它,你给他们的东西——可能是多次提交——现在被一个单一的压缩提交所取代:
I--J <-- refs/pull/125/head
/
...--G--H--K--L--S <-- theirbranch
在这里,他们的提交 S
代表您在 I-J
中所做的 工作。你所有的实际工作,以及至少你的一些提交日志消息,都被他们的 squash 所取代(他们可能会或可能不会保留你的一些提交日志消息)。您应该获得提交 S
(进入您的 upstream/theirbranch
)并使用它,放弃您的 I-J
原件。
rebase-and-merge 对您的 PR 有何影响
如果有人在上游获取您的 PR 并使用 REBASE AND MERGE 按钮,GitHub 的 Git 软件将 复制每个您的提交 到 new-and-?改进了吗?提交。他们会这样做即使没有真正的需要。例如,您可能已经仔细地将 I-J
重新定位到他们的 L
上,这样您就可以:
I'-J' <-- refs/pull/125/head
/
...--G--H--K--L <-- theirbranch
在你发布 PR#125 的时候。但是他们点击了“错误的”8 按钮,因为他们喜欢线性图,所以现在在 他们的 存储库中他们有:
I'-J' <-- refs/pull/125/head
/
...--G--H--K--L--I"--J" <-- theirbranch
其中 I"
是 I'
的副本,J"
是 J'
的副本。这些副本保留了您的原始日志消息,并且您是作者,但它们具有新的和不同的哈希 ID。
您需要放弃 您的 原始提交以支持这些“新的和改进的”提交。不过,git rebase
中有一个好东西——另一个方便的技巧——可以让你更轻松。
8我只称这是“错误的”,因为它不必要地重复了提交。对于你的 PR 挂起提交 H
的情况,提交 did 实际上 必须 重新设置基线,以使图形线性化。但是,效果是您是作者,他们是提交者,并且这些提交具有新的和不同的哈希 ID。
小窍门:变基知道副本
当有人做了这种“rebase and merge”,你几乎总是可以用git rebase
yourself to replace 你的原始提交——即使你自己对它们进行了多次变基——以及它们的新变基副本。原因是当 Git 去列出对 copy 的提交时,它会检查这些提交是否 已经在 这个地方您正在将 复制到 。也就是说,假设 你 在你的笔记本电脑上有这个:
K--L <-- feature2 (requires feature)
/
I--J <-- feature
/
...--G--H <-- upstream/theirbranch
你现在用 feature
做了一个 PR,他们按原样接受它,但使用了“错误”按钮,所以你的 git fetch upstream
结果是这样的:
K--L <-- feature2 (requires feature)
/
I--J <-- feature
/
...--G--H--I'-J' <-- upstream/theirbranch
您现在必须在提交 J'
之后进行提交 K'-L'
。
您 可以 明确地执行此操作 (git switch feature2; git rebase --onto upstream/theirbranch feature
),但是:
git switch feature2
git rebase upstream/theirbranch
会完成这项工作。原因是 Git 首先列出了要复制的四个提交 I-J-K-L
,然后查看提交 I'-J'
并且 发现这些是 [=74] 的副本=] 和 J
。 rebase 代码使用它 drop 完全提交 I-J
,导致:
I--J <-- feature
/
...--G--H--I'-J' <-- upstream/theirbranch
\
K'-L' <-- feature2 (HEAD)
事实上,如果您还 运行 git switch feature
然后 git rebase upstream/theirbranch
,您的 Git 将简单地 drop 提交 I-J
从复制过程中,离开你用这个:
...--G--H--I'-J' <-- upstream/theirbranch, feature (HEAD)
\
K'-L' <-- feature2
如果有人必须手动修复至少一个提交,这个不会(相当)有效。在过去,在 GitHub 获得一些额外的工具之前,这永远不会直接在 GitHub 上发生。现在(他们有这些工具)至少在理论上是可能的。
Ref: 大约 9 年前的以下问题:
Pull request without forking?
背景:
我正在研究 GitHub/Git,并且正在 运行 解决问题。我进行了不懈的搜索,但没有找到任何解决此特定问题的方法 - 我发现的最接近的是上面提到的问题。
问题:
我“分叉”了一个存储库,打算做一些工作,对我自己的分叉进行更改,然后创建一个拉取请求返回到原始项目,作为对其做出贡献的一种方式。
我终于想通了,并且能够成功创建包含我提议的更改的拉取请求。
请注意,我还想做其他事情来为这个项目做出贡献,在创建拉取请求后,我继续工作并向我的本地副本做出额外的提交,包括导入一些技术文档等。
显然,无论出于何种未知原因,在我发出拉取请求后,拉取请求“拥有”我对原始回购的分叉,此后我所做的任何事情都成为那个 pull request - 它是否相关并不重要,我是否将它推送到项目的分支,我是否将它添加到 PR,或者其他什么。它看起来就像变魔术一样,只有在我 remove/revert 我自己的存储库 fork 中的更改才能被删除。
这是否意味着与该项目有关的所有工作都必须完全停止,直到该 PR 被接受 and/or 拒绝?如果是这样,其他人,尤其是在单一代码库上工作的公司,如何设法完成工作?
当然,我相信这是可能的,人们总是这样做。
我所做的研究没有披露任何似乎可以解决这个特定问题的内容,但是对不同问题的其他答案似乎暗示了这样一个事实,即一旦您分叉回购并创建拉取请求,拉取请求 似乎“拥有”您本地存储库的那个实例 - 缓解这种情况的唯一方法是:
- 分叉回购。
- 创建 repo 的整个分支并开始工作。
- 提交该分支并创建拉取请求,然后放弃该分支.
要完成额外的工作,无论在项目的哪个位置,您都必须:
- 创建一个全新的分支。
- 做你想做的任何应该与原始作品分开的工作。
- 提交到新分支,创建拉取请求,然后放弃 那个 分支。
“冲洗并重复”任何您想做的额外工作,最终得到一个分支比圣诞树还多的叉子。
这引发了几个问题:
- 这是真的吗?我理解正确吗?
- 为什么?这似乎不必要地复杂和令人费解,尤其是对于单个贡献者。
最后也是最重要的问题:
3。如何清理我的本地副本?显然我应该克隆 repo,然后创建一个分支来工作,然后创建 pull request。 (即有没有办法把我更新的“主”,变成一个分支,然后重新创建原来的 main 这样我就可以创建额外的分支来做额外的工作?)
我犹豫是否只是“破解”现有的 repo 试图解决问题,因为我不想污染原始的 pull request 或搞砸上游项目。
谢谢!
当您执行拉取请求时,您建议将您的一个分支合并到原始存储库的一个分支中。每次更新分支时,合并都会更新。 这在您修复或审查后更新时非常有用。
针对您的案例的几种解决方案,简单的关闭您的拉取请求,为您要提交的每个主题创建一个分支(每个分支基于分叉存储库的主干)。
第二种方案: 创建一个分支来让你做额外的工作 回到主要分支(或主人) 强制已经提交的分支到原始提交 并推送它
git checkout -b my_second_feature
git checkout main
git reset --hard <commit_sha>
git push -f
注意:这很长,但您确实需要了解这些内容。我已经 运行 超出了 space(字符限制为 30k)所以我将把它分成两个单独的答案。 Part 2 is here; part 3 here.
虽然“拉取请求”不是 Git 的一部分(它们特定于 GitHub 1),即使没有特别提到 GitHub,我们也可以说一些关于它们的事情。然后我们可以稍后插入 GitHub-specific 项目。那么让我们从这个开始:
Git 就是关于 提交 。虽然 Git 提交包含文件,但 Git 并不是真正关于文件,而是关于提交。而且,虽然我们使用分支名称来 find 提交,但 Git 也不是关于分支名称:它实际上只是关于提交。
这意味着您需要了解有关提交的所有信息:什么是提交、每次提交以及连续的一串提交可以为您做什么。
所以我们将从一个提交的快速概览开始,然后查看一串连续的提交。
1Bitbucket 也有“拉取请求”,但它们略有不同,GitLab 有“合并请求”,这又是 same-but-different。所有这些都建立在 Git 适当的相同基础支持上。
提交
每个 Git 提交都有编号。但是,这些数字不是简单的连续计数数字:我们没有提交#1 后跟#2 和#3 等等。相反,每个提交都会获得一个唯一的 哈希 ID——在 所有存储库中都是唯一的 ,即使它们与您的存储库根本无关2—似乎是随机的,但实际上不是。3 散列 ID 又大又丑又不可能供人类使用:计算机可以处理它们,但我们脆弱的大脑会变得混乱。因此,在下面,我将使用伪造的哈希 ID,我只使用一个大写字母来代替真实的哈希 ID。请注意,要使这些哈希 ID 起作用,提交的每个部分 都必须 完全 read-only。也就是说,一旦您进行了新的提交,该提交就会永远冻结。那个特定的哈希 ID,无论它得到什么哈希 ID,都是针对 那 提交的,任何其他提交——过去、现在或未来——都不能使用该哈希 ID。
无论如何,每个 Git 提交都会存储两件事:
提交存储每个文件的完整快照(无论如何,Git 在您或任何人创建它时都知道).为了防止存储库变得非常胖,这些文件被 (a) 压缩和 (b) de-duplicated。因此,它们以只有 Git 可以读取的格式存储,没有任何内容,甚至 Git 本身也不能覆盖。正如我们将看到的,这解决了一些问题,但也带来了一个大问题。
提交还存储一些元数据,或有关提交本身的信息。这包括,例如,进行提交的人的姓名和电子邮件地址(来自他们的
user.name
和user.email
设置,他们可以随时更改,因此未经验证是不可靠的,但它仍然有用)。它包括一条 日志消息: 当您为自己的提交提供一条消息时,您应该写下对 为什么 您进行提交的解释。 您所做的——例如将 7 的一个实例更改为 14——是 Git 可以自行显示的内容,但是 为什么你把7换成14?是从几周到两周,还是因为 7 个小矮人都被克隆了?
在提交的元数据中,Git 为自己的目的添加了 previous 提交的原始哈希 ID 列表。这个列表通常只有一个元素长:对于 merge 提交(我们不会在这里介绍)它是 two 个元素长,并且至少任何 non-empty 存储库中的一次提交是 第一个 提交,其中没有任何先前的提交,因此此列表为空。
2这就是哈希ID必须如此大且丑陋的原因。严格来说,它们在两个永远不会 相遇 的存储库中不必是唯一的,但是 Git 不知道两个存储库是否或何时会相遇将来,如果两个 不同的 提交当时具有 相同的哈希 ID,就会发生不好的事情。我将这样的提交称为 Doppelgänger,一种预示着灾难的邪恶双胞胎。真正的灾难是——或者至少 应该是 ——只是这两个 Git 存储库的会议失败了。在一些非常旧的 Git 版本中,由于错误,确实发生了更糟糕的事情。在任何情况下,它根本不应该发生,哈希的大小有助于避免这种情况。
3当前哈希是提交中所有数据的 SHA-1 校验和,其中包括有关导致提交的提交的数据,因此它是导致该点的整个历史的校验和。 SHA-1 is no longer cryptographically secure. Though this does not break Git by itself, Git is moving to SHA-256.
提交链
鉴于上述情况,我们可以在一个很小的 three-commit 存储库中绘制三个提交,如下所示:
A <-B <-C
提交 C
是我们的第三次也是 latest-so-far 提交。它有一些 random-looking 哈希 ID,以及所有文件的快照。一个或两个文件 in C
可能与早期提交 B
中的所有文件不同,其余的与 B
中的相同因此从字面上与早期提交 B
共享。所以他们不接受任何实际的 space。修改后的文件确实需要一些 space,但它们是压缩的——有时压缩得非常厉害——可能几乎不需要任何 space。提交元数据有一点 space(顺便说一句,它也被压缩),但总的来说,这个 full-snapshot-of-every-file 可能不会占用太多 space.
同时,提交 C
包含早期提交 B
的原始哈希 ID。我们说C
指向B
。这意味着如果 Git 可以 找到 C
——我们稍后会看到它是如何做到的——Git 可以使用哈希 ID 在C
中也能找到B
。 Git 然后可以从 both 提交中提取两个快照中的所有文件,并比较它们。比较文件的结果是 diff: 说明,用于将 B
中的文件更改为 C
中的文件(反之亦然,如果你有差异按其他顺序完成)。
Git,以及像 GitHub 这样的网站,通常会 显示 提交作为差异,因为这通常比显示原始快照更有用.但是,如果您愿意,您可以轻松地获取快照:对于 Git 来说,有时 比获取差异 更容易 。 (由于 de-duplication 技巧,git diff
可以快速跳过相同的文件,但它仍然必须查看两个提交,而不仅仅是一个。所以对于哪个更容易有点混杂.)
提交 B
,作为提交,具有快照和元数据,并向后指向 still-earlier 提交 A
。但是提交 A
是 第一次 提交,因此它的元数据 不会 列出任何更早的提交。这意味着根据定义,其快照中的所有文件都是新文件。 (它们会被压缩并 de-duplicated 针对任何其他提交中的任何文件,但当时,这是 第一次 提交,所以它们只被压缩并且 de-duplicated against themselves。最后这意味着如果第一个提交包含一个大文件的 100 个相同的副本,那么在提交 A
.)
分支名称和其他名称
Git 需要一种快速的方法来找到某个链中的 last 提交。 Git 可能会迫使我们——使用 Git 的人——记下最后一次提交的哈希 ID,在本例中为 C
。我们可以将其保存在纸上、白板或其他东西上。但这很愚蠢:我们有一台 计算机 。为什么不让计算机将这些哈希 ID 保存在文件或其他东西中?事实上,为什么不让 Git 为 我们保存最近的哈希 ID ?
这正是分支名称的含义:一个保存 最新 提交的哈希 ID 的地方。 Git只需要最新的,因为最新的指向回second-latest,后者指向回一个still-earlier,以此类推。这会尽可能长时间地进行,仅当 是 没有更早的提交时才结束,这就是 Git 的工作方式:它从我们告诉它的提交开始——通常是通过分支名称—并且 向后.
让我们绘制一个简单的提交链,以散列 ID H
结尾(对于散列),并让分支名称 main
指向(包含散列 ID)H
:
...--G--H <-- main
现在让我们添加一个新名称,例如feature1
。这个名字必须指向一些现有的提交。我们可以选择 G
,或 H
,或一些更早的提交,但选择 H
似乎很自然,因为它是我们最新的:
...--G--H <-- feature, main
注意 Git 有很多种名称——不仅仅是分支名称——它们都做这种事情,即指向一个提交。所以我们可以创建一个 tag 指向提交 H
,例如:
...--G--H <-- feature, main, tag: v1.0
不过,大多数情况下,我们将只使用分支名称,这就是我现在在这里显示的全部内容。
在分支机构工作
Git 有它自己的特殊功能让我们做 工作 。正如我们之前提到的,提交快照的内容会一直冻结,并且只能由 Git 本身读取。所以我们实际上不能处理/使用提交中包含的这些文件。我们必须 Git 才能 提取 某处的文件。那个“某处”是我们的 working tre 或 work-tree.
Git还有一个很重要的东西,其中Git给出了三个名字:index,staging area,有时 缓存 。我们不会在这里讨论这一点,除了要注意当你 运行 git commit
, Git 实际上 使 来自文件的新提交 Git 的索引 / the-staging-area,而不是来自工作树中的文件。 所有 要提交的文件必须在暂存区中:这些是Git 知道的文件。提取提交会将提交的文件 复制到 暂存区以及工作树,以便它们从那里开始。
无论如何,一旦文件在您的工作树中,它们就只是您计算机上的普通文件。他们不再是 Git。它们来自 out of Git(来自提交),您可以将它们放回 into Git new commit later,但是当你做你的工作时,你在处理和使用 不在 Git 中的文件。只有 提交的 文件在 Git.
中您像往常一样使用 working-tree 文件和 运行 git add
进行工作。 (这会将您列出的文件的工作树版本复制回索引,以便它们可以提交。在 git add
阶段,Git 进行初始压缩,de-duplication。在 Git 的索引中看到的文件是 de-duplicated 之前的文件,换句话说,这意味着索引的副本大多不带 space,除了您拥有的任何文件changed-and-added。您可以添加一个 未更改的 文件:这只是一点点浪费时间,因为 Git 会发现它是一个副本并只保留原始文件。它是浪费便宜的 计算机 时间,而不是宝贵的 人类 时间,所以随意浪费吧!但是如果你知道某些文件很大并且这也会浪费你的时间,请跳过。)
无论如何,现在您的新提交已准备就绪,您 运行 git commit
。这个:
- 收集任何必要的元数据,例如您的姓名和电子邮件地址以及当前日期和时间;
- 获取当前提交的散列 ID——您之前检查过的用于填充工作树(和 Git 的索引)的哈希 ID;
- 冻结索引的快照;和
- 将所有这些作为新提交写出,它获得一个新的、唯一的哈希 ID。
如果你有:
...--G--H <-- feature, main
就在刚才,您的 当前 提交是 H
,因此您的新提交(我们称之为 I
)指向回H
:
I
/
...--G--H
然而,Git 确实需要知道 哪个分支名称 find H
.所以这两个名字之一有一个特殊的名字 HEAD
“附加到它”。假设这个名字 过去是,现在仍然是 feature
。那么我们的绘图现在看起来是这样的:
I <-- feature (HEAD)
/
...--G--H <-- main
也就是Git用HEAD
找名字feature
,先找哈希IDH
,现在写新的哈希ID[=72] =] 进入 feature
.
这样做的效果是当前分支名称,无论它是什么,现在都指向您刚刚进行的新提交。 (请注意,I
中的快照使用了索引 / staging-area,您更新它以匹配您的工作树,因此所有三个现在都匹配,就像它们在您开始“干净”结帐或git switch
.) 如果您使用通常的 modify-files-add-and-commit 过程进行另一个新提交,您将得到:
I--J <-- feature (HEAD)
/
...--G--H <-- main
如果你现在git switch main
或git checkout main
,Git所做的是:
- 删除所有commit-
J
文件并替换为commit-H
文件;和 - 将特殊名称
HEAD
附加到main
。
您现在拥有:
I--J <-- feature
/
...--G--H <-- main (HEAD)
你是 on branch main
,正如 git status
所说,你的工作树和临时区域是“干净的”(匹配 H
提交),你的更新文件已安全保存永远——或者只要提交本身持续——在提交 J
中,您可以使用名称 feature
.
如果愿意,您现在可以创建一个新分支,例如 feature2
,然后切换到它(使用 git branch
和 git switch
,或者组合 git switch -c
一次完成):
I--J <-- feature
/
...--G--H <-- feature2 (HEAD), main
当您在此新分支上进行新提交时,分支名称会自动更新以指向最新提交:
I--J <-- feature
/
...--G--H <-- main
\
K--L <-- feature2 (HEAD)
请注意,通过 H
并包括 H
的提交,在 Git 的术语中,在 所有三个分支 上。提交 I-J
当前 仅 feature
,提交 K-L
仅 feature2
.提交 H
是在 main
上的 最新 提交,尽管它不是有史以来的最新提交(即 您的[中的提交 L
=595=] 存储库,此时)。此外,提交 J
和 L
之间没有直接关系:它们只是可以说是表亲。他们是children的parent,parent,H
的children。
正在合并
要了解会发生什么,我们现在需要查看通常的 harder-case 合并。 Git 有一个简单案例的快捷方式,但出于各种原因(有些好,有些不太好),GitHub 尤其从不使用此快捷方式。一旦你理解了更一般的情况,简单的情况就更容易理解了。
在Git中,使用git merge
是关于组合工作。在namemain
中不画就画两个特征分支(可能还存在,只是挡住了我想画的)。我们先切换到分支feature
:
I--J <-- feature (HEAD)
/
...--G--H
\
K--L <-- feature2
我们当前的提交现在是 J
,我们现在会在我们的工作树中找到 J
的文件。我们现在 运行 git merge feature2
和 git merge
:
- 找到提交
J
(简单:只需阅读HEAD
然后feature
); - 找到提交
L
(也很简单:feature2
包含正确的哈希 ID); - 找到最佳共同起点提交。
最后一部分可能很难,尽管在这里很容易看出这是提交 H
:J
和 L
的祖父。如果 Git 现在将 H
中的 快照与 J
中的 快照进行比较,Git 将生成一个食谱,其中包含您在 feature
:
git diff --find-renames <hash-of-H> <hash-of-J> # what "we" did
通过 运行从 H
到 L
的 second diff,Git 将生成包含所有内容的配方在 feature2
:
git diff --find-renames <hash-of-H> <hash-of-J> # what "they" did
在这一点上,谁做了哪些工作并不重要:唯一重要的是“我们”更改了哪些文件,“他们”更改了哪些文件,以及我们对每个文件做了哪些更改.两个 git diff
解决了这个问题。
如果Git可以单独组合这两组变化,它可以应用组合的变化到来自 H
的 快照。不管你喜欢怎么看,这要么保留我们的更改并添加他们的更改,要么将两个更改加在一起,或者其他什么。 Git 假定最终结果是 存储在新提交中的正确快照 。
如果 Git 不能 单独组合这些更改,Git 将在合并中间停止,并带有 合并冲突。程序员现在必须得出正确的结果。我们将跳过这部分。我们假设 Git 自己得出了正确的结果。在这种情况下,git merge
会为您继续 运行 git commit
。
通常,生成的提交 M
会将提交 J
作为其 parent。我们新的 merge commit does 实际上有 J
作为 a parent — 第一个 parent— 但也有提交 L
,我们在 git merge
命令行上命名的提交,作为它的 第二个 parent,像这样:
I--J
/ \
...--G--H M <-- feature (HEAD)
\ /
K--L <-- feature2
name feature
附加到 HEAD
,照常移动指向新提交 M
。但是由于 M
向后指向 J
和 L
,提交 K-L
是现在也“在”分支feature
。这意味着通过 M
的所有提交都在 feature
,而 feature2
仍然在 L
结束并且 不 包含提交 I-J
.
如果需要,我们现在可以删除名称feature2
:只有直接找到L
才有用,如果我们不这样做觉得需要直接找L
,有需要的时候看M
的第二个parent就可以找到。如果我们现在想向 feature2
添加更多提交,我们应该保留名称并执行此操作:
I--J
/ \
...--G--H M <-- feature
\ /
K--L--N--O <-- feature2 (HEAD)
如果愿意,我们现在可以再次将 feature2
合并到 feature
中:
I--J
/ \
...--G--H M-----P <-- feature (HEAD)
\ / /
K--L--N--O <-- feature2
制作一种鸭头图片,但我们也可以重画它而没有沿着顶行的肿块:
...--G--H--I--J--M-----P <-- feature (HEAD)
\ / /
K----L--N--O <-- feature2
(不确定这个长什么样子)。
Fast-forwarding
特殊的 short-cut 案例 Git 对 git merge
适用于这种情况:
...--D--E <-- main (HEAD)
\
F--G <-- bugfix
如果我们 运行 git merge bugfix
,Git 将找到提交 E
和 G
,然后找到 merge base 的 E
和 G
:两个分支上的最佳提交。但那是提交 E
本身,即 当前提交 .
Git 可以 继续 diff E
与自身比较,发现没有变化。然后它 可以 将 E
与 G
进行比较以找到它们的变化。然后它将应用这些更改 到 E
并提出一个新的提交 H
,并给它两个 parents:
...--D--E------H <-- main (HEAD)
\ /
F--G <-- bugfix
提交 H
将是一个 merge 提交,有两个 parent,就像“真正的合并”情况一样。但显然将 E
与自身进行比较是愚蠢的,添加他们的更改只会让我们得到一个 cmmit H
其快照 完全匹配 其提交 G
中的快照。所以 Git 在这种情况下,除非我们告诉它,否则根本不会费心合并。
相反,Git 将执行它所谓的 fast-forward 合并 。这意味着 Git 直接检查提交 G
,同时向前拖动当前分支名称:
...--D--E
\
F--G <-- bugfix, main (HEAD)
现在根本没有理由在图中画出扭结:
...--D--E--F--G <-- bugfix, main (HEAD)
和删除名字bugfix
显然是足够安全的,虽然据推测main
会在后面更进一步。
要抑制 fast-forward 而不是合并,我们会运行 git merge --no-ff
。 GitHub 实际上总是这样做,因此您不会看到 fast-forward 合并发生 on GitHub;但很高兴了解他们。
何时删除名称
何时以及是否删除其他分支名称由用户决定。请注意,删除 name 不会删除 commits: 它只会让它们更难找到。但还有一件事要知道。假设我们有:
...--G--H <-- main
\
I--J <-- bugfix (HEAD)
其中提交 I
和 J
根本不起作用。你会 运行:
git switch main
git branch -d --force bugfix
放弃您修复错误的尝试。这给你留下了:
...--G--H <-- main
\
I--J ???
提交 I-J
仍然存在,但除非你写下 J
的哈希 ID,否则你可能永远无法 find 再次提交 J
。
Git 最终会检测到提交 J
是 unreachable(您无法找到它)并将删除它真实的。一旦 J
消失,提交 I
也是如此。你有一个宽限期,通常至少 30 天,在此期间 Git 不会 执行此操作,以及各种 Git 命令来帮助查找 accidentally-lost 承诺。但是,如果您不费心找到它们并重新添加名称,Git 用来跟踪“丢失”提交的“reflog 条目”最终会过期,然后——当 Git 绕过时进行维护和清洁工作——“丢失”的提交将真正从这个存储库中消失。因此,虽然提交是 read-only,但它们只是“大部分是永久性的”。只要您能找到它们,它们就会保留在您的存储库中(然后会更长一点)。
克隆、远程和多个存储库
Git 不仅仅是一个版本控制系统 (VCS);这是一个 分布式 VCS (DVCS)。 Git 进行此分发的方式是允许——或者更确切地说,强烈鼓励——存储库的许多副本存在。因此,一个 Git 存储库 是:
- a collection 提交和其他 Git objects,其中一些或全部也可能在其他存储库中;和
- 一个 collection 名称,例如分支和标签名称,可以帮助您(和 Git)找到 提交和其他内部 objects.
这些存储为 refs/
下的两个简单 key-value databases. The keys in the names database are branch names like refs/heads/main
, tag names like refs/tags/v1.2
, and many other kinds of names. Each name lives in a namespace。每个名称只存储一个哈希 ID。
objects 数据库中的键是散列 ID。此数据库中的每个 object 都有一些 Git 内部 object 类型(提交、树、blob 或带注释的标记)。提交 objects,连同支持树和 blob objects,结束存储你的文件;并且您将主要处理提交,通常不必太在意这些细节。
由于提交哈希 ID 全局 唯一,您的 某些存储库的克隆中的 object 数据库密钥是 与同一存储库的每个其他克隆中的密钥相同。当您克隆存储库时,您将获得所有或几乎所有的 提交 和支持 object。但是 names 数据库在 你的克隆 中与他们的完全分开。
这意味着存储库的克隆开始时根本没有分支名称。你运行:
git clone <url>
或:
git clone -b <branch> <url>
并且您的 Git 软件会创建一个新的 totally-empty Git 存储库以启动。您的 Git 软件使用您的 Git 存储库(我喜欢将其缩短为“您的 Git”)调用 他们的 Git软件并将其指向 他们的 Git 存储库(“他们的 Git”)。他们的 Git 列出了他们所有的分支和标签以及其他名称和哈希 ID,然后你的 Git 要求它想要复制的 objects(通常,他们全部)。对于您将要获得的每个提交,他们的 Git 有义务提供该提交的所有 parent,以及 parent 的 parent,等等.所以你最终将每个提交复制到你的 Git.
现在您拥有所有 提交 (并支持 object),您的 Git 需要他们的每一个 分支名称 和 重命名 它们。这个重命名过程利用了“远程”的概念。
一个remote,在Git中,只是一个短名称,至少存储了一个URL(以后你可以让它存储各种额外的功能). URL 是您在 git clone
中键入的那个,第一个“远程”的 名称 始终是 origin
. 4 所以 origin
从现在开始意味着 我从 克隆的 URL,除非你改变了一些东西。
Git 使用这个名称——origin
字符串——为 their 分支组成 new names名字。他们的main
变成了你的origin/main
;他们的 debug
变成了你的 origin/debug
;如果他们有 feature/tall
,你会得到 origin/feature/tall
;等等。这些名称实际上并不是 branch 名称;我喜欢称它们为 remote-tracking names.5 它们的功能是记住,对于您的 Git 存储库,什么 他们的 分支名称,什么提交 每个选择的名称,你的 Git 上次从他们的 [=824] 获得更新=].
重命名完成后,您的 Git 已经为他们拥有的每个分支名称创建了 remote-tracking 名称。您拥有他们所有的提交,并且可以找到所有这些,因为您的 remote-tracking 名称拥有与其分支名称相同的哈希 ID,他们正在使用它们来查找他们的提交。
现在,在您的 git clone
完成并 returns 控制您开始工作之前不久,您的 Git:
- 根据您提供的
-b
参数在您的存储库中创建一个新的 分支名称 :如果您说-b bugfix
,您的 Git找到与他们的bugfix
相对应的origin/bugfix
并创建 你自己的bugfix
,指向相同的提交。 - 签出(切换到)这个新分支。
所以现在你的克隆中有 一个 分支,匹配他们的一个分支。如果您不使用 -b
,您的 Git 会询问他们的 Git 他们推荐什么名字。通常的标准推荐是他们的主要分支(现在通常是main
;过去是master
)。
获得克隆后,您可以使用 git remote add
添加 更多 个遥控器。这需要一个名称 for 遥控器,以及一个 URL;它设置了遥控器,但还没有 运行 git fetch
。现在是时候讨论获取和推送了;查看其他答案。
4您可以选择其他名称,但这样做几乎没有任何意义。使用 origin
作为“主遥控器”的名称。您可以随时重命名遥控器,因此即使您不打算保留开头的 URL,让 git clone
默认为 origin
也可以正常工作。
5Git 调用它们 remote-tracking 分支名称,击败可怜的重载词 branch
从血腥、畸形的野兽到 barely-recognizable-splotch。说真的,把branch这个词放在这里,没有任何帮助。
第 2 部分—参见
git fetch
到 运行 git fetch
,你选择一个遥控器并调用它作为 git fetch <em>remote</em>
.如果您省略远程名称,Git 会从某个地方选择一个远程,或者尝试使用默认名称 origin
,具体取决于很多配置项。如果你只有 有 一个名为 origin
的单一标准遥控器,运行ning git fetch
没有额外的参数是好的:你没有别的意思无论如何。
fetch 的作用是:
- 调用任何 Git 软件回答存储的 URL;
- 让他们列出所有名称(分支、标签等)和相应的哈希 ID;和
- 从他们那里获取他们拥有而您没有的任何提交。
请注意,这与我们对 git clone
执行的操作相同,只是现在不是“获取他们所有的提交”,而是“获取他们拥有但我们没有的提交”。由于提交具有全局唯一的 ID,我们可以很容易地告诉我们(比如说)提交 a123456
因为我们有一些 ID 为 a123456
的对象,并且我们缺少——因此需要——b789abc
因为我们没有这样的ID。获得他们的 new-to-us 提交后,我们的 Git 现在更新相应的 remote-tracking 名称。
换句话说,git fetch
与 git clone
做的事情几乎相同,只是 我们的 Git 存储库已经存在,我们可能得到的数据要少得多,而且我们没有最后的“创建分支并检查它”的步骤。由于我们可以有多个 remote,我们可以 运行:
git fetch origin
并更新我们所有的 origin/*
名称,然后 运行:
git fetch upstream
并更新我们所有的 upstream/*
名称,如果我们使用 git remote add
添加一个名为 upstream
的 second 遥控器。
要一次更新所有我们的遥控器,我们可以使用git fetch --all
或git remote update
;两者本质上做同样的事情。请注意,--all
到 git fetch
表示 所有遥控器 ,而不是 所有分支: 我们已经获得了所有分支。 (我提到这个是因为人们一直认为 --all
意味着 所有分支 而它从来没有。)
如果我们愿意,我们可以限制我们的git fetch
像这样:
git fetch origin main
这让我们的 Git 像往常一样打电话给他们的 Git 并列出事情,但是这次,我们的 Git 只麻烦 询问 他们在 main
上的任何 new-to-us 提交。当一切都完成后,我们的 Git 然后更新我们的 origin/main
(我们知道 origin
的 main
现在在哪里,所以我们相应的 remote-tracking 名称,即, origin/main
,可以更新)。如果他们在 dev
上有新的提交,我们不会得到它们,我们也不会更新我们的 origin/dev
;我们的Git被告知只需要main
。
在一些(罕见的)设置中,这种事情可以节省大量数据传输。 Git 因此提供了一种叫做 single-branch 克隆 的东西,其中 git fetch
通过 default 来做到这一点。这是人们尝试使用 --all
的地方(但它不起作用):要从 single-branch 克隆中获取其他分支,您必须添加它们——参见 the git remote
documentation——或使用显式 refspec。不过,出于 space 的原因,我们不会在这里适当地介绍 refspecs。
由于您将有 两个 遥控器,一个用于您的 GitHub 分支,另一个用于您分支的 GitHub 存储库,您将想要 运行 git fetch
两次,或者偶尔使用 git remote update
或 git fetch --all
。除此之外 - 并且拥有 upstream/*
,如果你像大多数人一样调用第二个远程 upstream
- 你的存储库仍然像任何其他存储库一样。
git push
git push
命令与 git fetch
非常相似,有几个主要区别:
首先当然是
git push
表示发送东西。您使用git fetch
来 获取新提交 (和其他内部对象) 来自 一些其他 Git (一些其他软件工作与其他一些存储库).. 你使用git push
来 发送 新的提交,通常是你所做的——但它们 可以是 你的例如刚从upstream
到其他 Git.其次,一旦您发送了这些提交,您通常会要求 其他 Git 设置 它的分支名称之一。在推送方面没有这样的东西,比如 remote-tracking 名称。
最后一部分意味着您必须权限才能写入存储库。 Git 本身根本没有真正的访问控制,但大多数网络托管网站,包括 GitHub,都添加了他们的访问控制。 GitHub 在这里特别添加了很多花哨的控件。您 and/or 其他人是否使用它们取决于您和他们。
要执行 git push
,您通常 运行 一个简单的:
git push <remote> <name>
这表示您希望 Git 查看您名为 name
的分支上的提交,找到哪些提交origin
的另一个 Git 的新手,将 发送给 Git 的 ,然后礼貌地询问他们是否愿意,拜托,设置 他们的 名称 name
指向与您的 name
[=388= 相同的提交]指向。
换句话说,您要求他们创建或更新与您的分支同名的分支。一般来说,他们会接受这个 当且仅当 这只是 添加 到他们的分支(当然你有权限)。也就是说,当我们有:
...--G--H <-- main (HEAD), origin/main
因为我们的 main
与来源的 main
匹配,我们添加了一两个新的提交:
I--J <-- main (HEAD)
/
...--G--H <-- origin/main
我们 运行 git push origin main
,我们的 Git 调用他们的 Git,向他们发送提交 I-J
,并要求他们设置他们的 main
指向 J
.
如果他们的 main
仍然指向 H
——或者不知何故,指向 G
因为有人让他们 drop H
—他们会很高兴地接受我们的请求来添加到他们的 main
。由于我们的 Git 看到他们接受了,我们最终得到:
...--G--H--I--J <-- main, origin/main
知道 origin
的 main
现在指向提交 J
。
但是假设其他人出现并添加了一些提交 K
到 他们的 main
:
...--G--H--K <-- main [over on origin]
我们的请求现在将要求他们放弃他们的提交K
,这将给他们留下:
...--G--H--I--J <-- main
\
K ???
他们会说 no,您将收到的错误消息是 not a fast-forward(记住那些来自合并? 这是同一个想法)。
您可以使用 --force
或 --force-with-lease
,尝试让他们接受更改,丢失他们的新提交,但是 通常 这是错误的要做的事。然而,对于 GitHub 的使用,有时这是 正确 在你的分叉上做的事情!我们稍后再谈这个。
还有一种方法可以使用 git push
删除名称。其实有好几个,不过最清楚的大概就是git push --delete <em>remote branch</em>
: git push --delete origin foobranch
would在 origin
上丢弃 foobranch
。这对您的笔记本电脑存储库没有影响。
Git集线器“分支”
我们现在有足够的背景来定义 GitHub 的 FORK 按钮是如何工作的。您选择一些不属于您自己的现有存储库并单击它,GitHub 将在 GitHub 上创建一个新存储库 是你自己的。这个 GitHub“fork”是一种克隆,但有几个添加的功能和一个变化。
既然您知道 git clone
复制 没有 分支,那么变化就很明显了。当您使用 GitHub 的 fork 按钮时,它会复制 all 个分支。您的新克隆与原始克隆具有相同的提交和分支集,而常规 clone-to-your-laptop 获得所有提交,但分支的 none,并且然后创建一个 new 分支,使 accidentally-on-purpose 与 origin
的分支之一完全匹配。 fork 按钮使 all 您的 fork 中的分支名称与所有其他存储库的分支完全匹配。
添加的功能包括提出 拉取请求 的想法,我们稍后会回过头来。在 GitHub 方面——你不可见,但对 GitHub 本身非常重要——添加的功能包括不使用任何 space 来保存提交:你的 fork 只是 re-uses 来自原始的提交。没有提交可以改变,所以这很好;唯一可能发生的问题是如果要删除提交,因此 GitHub 只是安排永远不会删除提交。1
不过,一旦你做了分叉,那些分支名称,在你分叉的 GitHub 上,不要再更新,直到并且除非 你去做吧。您可以从 GitHub 的 Web 界面执行一些操作(例如,您可以删除分支名称),或者您可以像往常一样在笔记本电脑上使用 git push
。
因此,一旦您 有了叉子,您就会想将该叉子克隆到您的笔记本电脑上,然后,在 笔记本电脑上, 添加第二个 URL 到你分叉的存储库。标准的 GitHub 命名方式 URL 是使用远程名称 upstream
。我个人不喜欢这个名字,因为 upstream 这个词在 Git,2 中已经有多种含义,但是 运行 ,如果你将 ssh://github.com/them/repo.git
分叉到 ssh://github.com/you/repo.git
,你会 运行:
git clone ssh://github.com/you/repo.git
cd repo
git remote add upstream ssh://github.com/them/repo.git
git fetch upstream
您现在有 origin/*
和 upstream/*
个名字。我们现在来了解其中一个方便的技巧。
1这意味着如果有人不小心将密码放在 GitHub 上,它可能永远存在,即使他们很快 force-push 隐藏它。 GitHub 支持可以“真正”清除提交,但总的来说,始终考虑任何意外暴露的秘密,即使是一瞬间,也会永远受到损害。
2所以,至少比branch这个词好。
小窍门:更新你的叉子
拥有 运行 git fetch upstream
或 git remote update
以便您的 upstream/*
名称全部更新,您可能希望让您自己的 fork 拥有他们的所有更新相同的 分支 名称。这意味着对于每个 upstream/whatever
,您想要 运行:
git push origin upstream/whatever:whatever
这种 git push
使用 refspec,我们在左侧放置一个“来源”名称,然后是一个冒号,然后是“目的地”右边的名字。 Git 将从给定的源(我们的本地 upstream/whatever
remote-tracking 名称)中提取提交,但是当它们到达目的地(origin
)时,要求目的地进行设置他们的destination-side名字(他们的whatever
)。
您可以使用循环来完成此操作,但还有更短的方法。请注意,您可能需要保护 *
字符免受 shell 的影响,具体取决于您的特定命令行解释器:
git push origin "refs/remotes/upstream/*:refs/heads/*"
我假设您需要双引号才能获得正确的保护。如果您不能使用双引号,请使用 是 所需的任何引号机制(可能根本就是 none)。
这里,我们把名字全拼出来了:remote-tracking名字在refs/remotes/
name-space,而分支名字在refs/heads/
。 Git 匹配两颗星并在此处执行 每个 分支的常规 (non-forced) git push
。
您可以制作一个 Git 别名来执行此操作 git push
,以避免必须键入长命令并避免引用 refspec(一个简单的 Git 别名可以不通过shell):
[alias]
up2hub = push origin refs/remotes/upstream/*:refs/heads/*
请注意,这会嵌入名称 upstream
和 origin
,但现在您可以 运行:
git up2hub
在 git fetch upstream
成功后,更新您的 GitHub 分支。
拉取请求
现在我们终于到了问题的核心:拉取请求是如何工作的。 当您使用 CREATE PULL REQUEST 按钮时GitHub,你选择两个东西,尽管 GitHub 会为你默认其中一个:
- 你的分支中的一个分支名称;和
- other GitHub 存储库3 中的一个“基本分支”,您希望对其进行 PR。
GitHub 现在将 运行 进行“测试合并”,他们尝试对 您的 [=] 上的一组提交进行常规 git merge
=388=] 分支,通过合并请求导入到他们的存储库,到 他们的 分支的当前提示提交。也就是说,GitHub 获取你在 fork 中的每个提交,而他们在他们的存储库中根本没有,然后 将这些提交复制到他们的存储库 。 4
他们现在可以在 pull-request 的全名下找到您的提交,在 GitHub 上是 refs/pull/<em>人数</em>/头
。测试合并,如果它有效,将创建一个新的提交并使其可以通过 refs/pull/<em>number</em>/merge
访问。如果它因合并冲突而失败,PR 仍然会生成,只是没有refs/pull/<em>number</em> /merge
名称。
3您可以向 共享访问 存储库发出拉取请求,您和其他人都将推送到单个存储库,而不是每个到他们自己的叉子。在那种情况下,您会选择此存储库本身作为“其他”Git 存储库。但这只是一个特例,“其他”存储库是“这个”。
4这一切都是虚拟发生的,为了节省磁盘 space:他们的存储库有一个 link 回到你的,所以没有实际的复制,就像当你分叉他们的 repo 时,你没有真的 复制他们的提交。同样,Git 开始使用每个提交都有一个唯一的哈希 ID 的事实:您的提交和您的哈希 ID 保证与所有提交具有不同的哈希 ID。因此,当他们的 Git 软件试图找到提交 fee1cab
或任何哈希 ID,但找不到时,他们可以在您连接的分支中查看,它就在那里。你的叉子指回他们的回购,他们的回购回指回你的叉子,在一种乱伦循环中。
那么这一切意味着什么?
好吧,我们来看一个经典的例子。您 fork 一些存储库,并将其克隆到您的笔记本电脑并创建一个新分支:
...--G--H <-- main, my-feature-1, origin/main
您在 my-feature-1
分支上做了几个新的提交:
I--J <-- my-feature-1 (HEAD)
/
...--G--H <-- main, origin/main
您将这些提交发送到您的 GitHub 分支:
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
然后单击按钮进行 PR,在 他们的 分叉中,他们现在拥有:
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
提交 M
是 GitHub 的“测试合并”,有效; commits K-L
是他们在你忙于你的叉子和你的笔记本电脑时所做的新提交。
如果你现在继续制作:
I--J <-- my-feature-1, my-feature-2 (HEAD)
/
...--G--H <-- main, origin/main
在你的笔记本电脑上,然后进行两次 more 提交,你得到:
N--O <-- my-feature-2 (HEAD)
/
I--J <-- my-feature-1
/
...--G--H <-- main, origin/main
你可以 git push
这些到你的 GitHub 分支,这样它就有:
N--O <-- my-feature-2 [on your fork]
/
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
如果您现在使用此 my-feature-2
name 在 GitHub 上制作 PR ,则该 PR包含尚未在 main
中的提交 I-J-N-O
,因为他们尚未决定如何处理 PR#123:
N--O <-- refs/pull/124/head
/
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
(如果他们能够将 O
与 L
合并,或许还有一个测试合并)。
如果那不是你想要的,你 应该放在 GitHub 上的是:
I--J <-- my-feature-1 [on your fork]
/
...--G--H <-- main [on your fork]
\
N--O <-- my-feature-2 [on your fork]
你的 my-feature-2
现在只包含 两个 提交不在他们的 main
中,所以 PR#124 使他们的存储库看起来像这样:
I--J <-- refs/pull/123/head
/ \
/ M <-- refs/pull/123/merge
/ /
...--G--H---K--L <-- main
\ \
\ P <-- refs/pull/123/merge
\ /
N--O <-- refs/pull/124/head
第 3 部分:您的下一个绊脚石:变基
(
当他们(无论他们是谁)开始审查您的 PR 时,他们可能会:
- 接受原样;
- 请你修复里面的东西;
- 接受他们所做的改变;或
- 完全拒绝。
最后一个在这里不需要更多讨论,但其他三个需要。
如果他们“按原样”接受你的提交,他们可以选择使用三个不同的“接受这个 PR”MERGE 按钮, 在 Git 中心:
- 合并。这个很简单。
- 变基并合并。这个没那么简单。
- 压缩并合并。这个需要理解Git的“squash merge”,它根本不是合并。
如果他们自己进行任何更改,那很像 REBASE AND MERGE 的情况,正如我们将要看到的那样。如果他们想要 you 进行更改,you 将需要在您的笔记本电脑上使用 git rebase
,之后您将需要使用 git push --force
或 git push --force-with-lease
更新您的 GitHub 存储库。5
5从技术上讲,您可以删除并 re-create 您的分支,而不是 force-pushing。不过,我认为这会破坏现有的 PR(我还没有尝试过)。在任何情况下,force-push 选项都是人们在实践中使用的选项。
Rebase 是关于复制 提交
我们使用 git rebase
将现有的提交复制到我们使用 而不是 原件.
在我们看那个之前,让我们看一下复制一个提交的命令。正如我们所知,任何提交都不能 更改 ,但我们始终可以提取提交,或将其转换为差异,或其他任何方式。我们可以使用这个 属性 来获取一些现有的提交,将其转化为更改,然后再次应用这些更改,或应用到其他地方。 Git 调用此操作 cherry-picking,并使用命令 git cherry-pick
来执行此操作。
通常,出于某种原因,我们可能会使用 git cherry-pick
作为获取提交的 one-off 副本的快速方法。例如,也许有人有一个好主意或错误修复,我们现在需要 在 我们的 分支上,我们会想办法来处理这将在以后造成的混乱。我们在我们的分支:
...--J--K <-- feature (HEAD)
与此同时,在他们的分支上,他们修复了一些困扰我们的讨厌的小错误:
...--P--C--R--S <-- theirs
(我们将在 P
之后调用 fixing-commit,“parent” 提交,C
,“child”,此处,而不是我通常使用的字母 Q
)。我们运行:
git cherry-pick <hash-of-C>
告诉Git:去弄清楚whoever-it-is在提交C
中做了什么,P
的child,通过比较 P
和 C
看看有什么变化。然后在我的提交 K
上进行 相同的 更改,并从中进行新的提交。 结果图如下所示:
...--J--K--C' <-- feature (HEAD)
Git 会将他们列为提交 C'
的作者,以及 re-use 他们的提交信息;我们将被列为 C'
的 提交者 。 (大多数时候,作者和提交者是同一个人,但不一定;像这样的副本通常将他们保留为作者,并保留他们的提交 date-and-time 邮票。)
Git 实际实现此“复制他们的更改”的方式是 Git 必须弄清楚要触摸哪些文件以及这些行去了哪里。为此,Git 从 P
到 C
进行比较,以查看 他们 做了什么,然后从 [=39 进行第二次比较=] 到 K
看看 我们 做了什么。回想一下 git merge
是如何工作的:这是合并的核心: 合并两个差异并将合并后的差异应用于合并基础 。 Git 只是强制提交“基础”P
,不管其他。我们的提交是提交 K
,他们的提交是提交 C
,仅此而已——除了当 Git 提交合并时,它使它成为一个普通的 single-parent 犯罪。提交 C'
仅指回 K
,而不是 P
或 C
.
最终结果,如果一切顺利——如果没有合并冲突——我们有来自提交 C
的 更改,但应用在这里,在提交 K
。因此,新提交 C'
是 C
的 副本,因为它:
- 有 一些 与
C
相同的元数据:特别是日志消息和作者身份;和 - 具有相同的 差异 ,除了在我们必须解决合并冲突时添加或删除的任何内容。
有了git rebase
,我们可以:
- 采取一系列现有的提交并简化移动他们,或者
- 使用 interactive rebase,做各种额外的摆弄。
Interactive rebase 是一个很大的 blog-post 或单独的一篇文章,我们不会在这里详细介绍。我们只看一个简单的 rebase-to-move 作业。我们开始于,例如:
I--J <-- feature (HEAD), origin/feature
/
...--G--H--K--L <-- upstream/main
假设,在这一点上,我们喜欢一切关于I-J
除了他们不会追赶K-L
。让我们再添加一个问题:存在 合并冲突 ,例如,因为我们在 J
中接触的其中一条线与我们(或他们)在 [=] 中接触的其中一条线相邻70=]。这个合并冲突,或者 Git 认为是冲突的事情,很容易解决,但是 Git 不会做,所以我们必须自己做。
此时,我们只是运行:
git rebase upstream/main
请注意,我们不必在这里使用 分支 名称,我们甚至不必更新 origin
:我们可以在任何提交上变基我们已经为该提交使用任何名称。 name upstream/main
找到 commit L
,这就是我们要复制 I
和 J
到 come-after 的 commit,所以这就是我们给 git rebase
这里.
Git 将在内部保存提交 I
和 J
的原始哈希 ID,它们是要复制的提交。 (Git 如何知道这一点——以及我们如何改变被复制的内容,到哪里——在别处得到回答,但请注意我们的 当前分支名称 指向 J
并且提交 I-J
是唯一可以从当前分支 仅 访问的提交。)然后,Git 将切换到提交 L
——那个我们命名了——在 Git 所谓的 分离 HEAD 模式中。这里根本没有当前分支: HEAD
直接指向当前提交。所以现在我们有了这个:
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main, HEAD
Git 现在做一个 cherry-pick 复制 I
。这行得通,并且 Git 进行了新的提交 I'
:
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main
\
I' <-- HEAD
Git 现在尝试第二次 cherry-pick 复制 J
。这个因 合并冲突 而失败。我们通过在编辑器中打开冲突文件,将 正确的合并结果 放入文件,并将文件写回我们的工作树来解决冲突,然后 运行ning git add
在那个文件上。然后我们使用:
git rebase --continue
使 Git 恢复 committing-and-copying-and-so-on。 Git 为 J'
提交(副本,我们的决议到位,如 git add
-ed):
I--J <-- feature, origin/feature
/
...--G--H--K--L <-- upstream/main
\
I'-J' <-- HEAD
如果有更多提交要复制,Git 会尝试 cherry-picking 下一个。然而,事实上,copy 已经没有什么可做的了,所以 git rebase
进行最后的操作:
- rebase 将名称
feature
拉到这里,无论HEAD
在哪里;和 - 变基 re-attaches
HEAD
所以我们现在有:
I--J origin/feature
/
...--G--H--K--L <-- upstream/main
\
I'-J' <-- feature (HEAD)
原来的I-J
提交仍然存在。如果您记下了它们的哈希 ID(或者它们仍然在您的屏幕上),您仍然可以看到它们。使用名称 origin/feature
,您仍然可以看到它们。但是,如果您使用 name feature
查找提交,您将找到新的副本 而不是 原件。
更新拉取请求
要更新我们现有的拉取请求(可能是 PR#125,feature
到我们分叉的存储库中的分支名称),我们只需告诉 GitHub 接受这两个新提交。一个普通的:
git push origin feature
不会工作,因为 Git 在 GitHub 会 object: 嘿,如果我更新我的 feature
,我会失去这两个非常有价值的 I-J
提交!不是快进! 拒绝!我们必须强制它更新,使用新的替换I'-J'
。 6 所以我们 运行:
git push --force-with-lease origin feature
或更短的 --force
变体。 (--with-lease
增加了一些错误检查,这是个好主意,但仍然感觉很新奇,至少对我来说打字很笨拙。这是 15+ 使用 Git 的缺点之一年。)我们告诉 GitHub 我们是认真的,他们接受了新的提交。
因为有一个公开的 PR 引用 name feature
,GitHub 将在此时再次尝试合并。他们上次尝试此合并时,与提交 L
发生合并冲突。这一次,我们 将 添加到他们的提交 L
,因此没有合并冲突,并且 PR 可能会被接受 as-is.
如果我们需要进行额外的更改,我们可以使用花哨的交互式 rebase,或者做额外的提交然后压缩,或者任何我们喜欢的。
6如果Git知道这些是进化的替代品就好了。
挤压
Git 提供它拼写的内容 git merge --squash
。这不是合并,就像 fast-forwarding 不是合并一样:没有最终的 merge commit。但它 是 合并,就像 git merge
做一对差异和组合工作一样:这是 combining-work 部分。
给定:
...--G--H--I--J <-- br1 (HEAD)
\
K--L <-- br2
如果我们 运行 git merge --squash br2
, Git 将:
- 像往常一样找到合并基地
H
; * 像往常一样做两个差异; - 合并差异,如果存在合并冲突则停止并让我们修复混乱;或
- 如果没有冲突,无论如何都停止。
我相信,这个“无论如何停止”只是最初实现的一个意外——git merge
有一个 --no-commit
标志来 使 它停止,它应该与 --squash
分开,但 --squash
始终打开 --no-commit
。但是,无论如何,我们现在必须通过 运行ning git commit
完成操作,这会像往常一样提交 Git 的索引和您的工作树中的内容,并且 不进行合并提交。不是合并 M
和 两个 parent,我们得到一个简单的普通提交 S
——“挤压合并”——一个 parent 像往常一样:
...--G--H--I--J--S <-- br1 (HEAD)
\
K--L <-- br2
新提交 S
中的 快照 与我们 将 拥有的快照相同,如果我们做了常规合并,但 S
不会 link 回到 L
,现在唯一 有用的 与名称 br1
相关就是 删除 它。7 这两个提交 K-L
实际上已经合并,或者 squashed,进入单个提交 S
。提交 S
与 K-L
具有相同的 效果 ,因此 K-L
现在无用,应该被遗忘。
7也可以这样做,但这超出了这个答案的范围。
squash-merge 如何与 GitHub PR
相关如果上游有人接受你的 PR 并且 squash-merge 它,你给他们的东西——可能是多次提交——现在被一个单一的压缩提交所取代:
I--J <-- refs/pull/125/head
/
...--G--H--K--L--S <-- theirbranch
在这里,他们的提交 S
代表您在 I-J
中所做的 工作。你所有的实际工作,以及至少你的一些提交日志消息,都被他们的 squash 所取代(他们可能会或可能不会保留你的一些提交日志消息)。您应该获得提交 S
(进入您的 upstream/theirbranch
)并使用它,放弃您的 I-J
原件。
rebase-and-merge 对您的 PR 有何影响
如果有人在上游获取您的 PR 并使用 REBASE AND MERGE 按钮,GitHub 的 Git 软件将 复制每个您的提交 到 new-and-?改进了吗?提交。他们会这样做即使没有真正的需要。例如,您可能已经仔细地将 I-J
重新定位到他们的 L
上,这样您就可以:
I'-J' <-- refs/pull/125/head
/
...--G--H--K--L <-- theirbranch
在你发布 PR#125 的时候。但是他们点击了“错误的”8 按钮,因为他们喜欢线性图,所以现在在 他们的 存储库中他们有:
I'-J' <-- refs/pull/125/head
/
...--G--H--K--L--I"--J" <-- theirbranch
其中 I"
是 I'
的副本,J"
是 J'
的副本。这些副本保留了您的原始日志消息,并且您是作者,但它们具有新的和不同的哈希 ID。
您需要放弃 您的 原始提交以支持这些“新的和改进的”提交。不过,git rebase
中有一个好东西——另一个方便的技巧——可以让你更轻松。
8我只称这是“错误的”,因为它不必要地重复了提交。对于你的 PR 挂起提交 H
的情况,提交 did 实际上 必须 重新设置基线,以使图形线性化。但是,效果是您是作者,他们是提交者,并且这些提交具有新的和不同的哈希 ID。
小窍门:变基知道副本
当有人做了这种“rebase and merge”,你几乎总是可以用git rebase
yourself to replace 你的原始提交——即使你自己对它们进行了多次变基——以及它们的新变基副本。原因是当 Git 去列出对 copy 的提交时,它会检查这些提交是否 已经在 这个地方您正在将 复制到 。也就是说,假设 你 在你的笔记本电脑上有这个:
K--L <-- feature2 (requires feature)
/
I--J <-- feature
/
...--G--H <-- upstream/theirbranch
你现在用 feature
做了一个 PR,他们按原样接受它,但使用了“错误”按钮,所以你的 git fetch upstream
结果是这样的:
K--L <-- feature2 (requires feature)
/
I--J <-- feature
/
...--G--H--I'-J' <-- upstream/theirbranch
您现在必须在提交 J'
之后进行提交 K'-L'
。
您 可以 明确地执行此操作 (git switch feature2; git rebase --onto upstream/theirbranch feature
),但是:
git switch feature2
git rebase upstream/theirbranch
会完成这项工作。原因是 Git 首先列出了要复制的四个提交 I-J-K-L
,然后查看提交 I'-J'
并且 发现这些是 [=74] 的副本=] 和 J
。 rebase 代码使用它 drop 完全提交 I-J
,导致:
I--J <-- feature
/
...--G--H--I'-J' <-- upstream/theirbranch
\
K'-L' <-- feature2 (HEAD)
事实上,如果您还 运行 git switch feature
然后 git rebase upstream/theirbranch
,您的 Git 将简单地 drop 提交 I-J
从复制过程中,离开你用这个:
...--G--H--I'-J' <-- upstream/theirbranch, feature (HEAD)
\
K'-L' <-- feature2
如果有人必须手动修复至少一个提交,这个不会(相当)有效。在过去,在 GitHub 获得一些额外的工具之前,这永远不会直接在 GitHub 上发生。现在(他们有这些工具)至少在理论上是可能的。