TL;DR
切换分支可能需要更改 Git 索引和工作树的内容。这可能会丢失您正在做的工作。你遇到过这样的情况。通常,您必须强制 Git 丢失工作(尽管旧的 git checkout 命令存在一些小问题,很容易破坏未保存的工作,在新的 git switch 中已修复)。
这里有很多要知道的。
长
您将许多概念混合在一起,当您使用 Git 时,您需要在脑海中保持独立。特别是,您似乎对 Git 的介绍很糟糕。一个好的开始:
所以 Git 基本上只是一个充满提交的大型数据库(以及其他支持对象,此外还有一些较小的数据库)。提交是 Git 的 raison d'être。
众所周知,what someone tells you three times is true,? 所以接下来要学习的是什么是提交。这有点抽象:很难指着房间里的东西说那里,那是一个提交!,因为没有现实世界的类似物。但在 Git 中:
-
每个提交都编号,具有一个看起来像随机垃圾的唯一编号。它实际上是一个密码校验和(让人联想到加密货币,这里实际上存在一种关系),用hexadecimal 表示,但我们可以把它看作是一个明显随机的垃圾字符串,没有人会记住。然而,对于一个特定的提交来说是唯一的:一旦一个数字被任何一个提交使用,任何地方的任何人都不能将它用于任何其他提交。1
这就是两个不同的 Git(实现 Git 的两个软件,使用两个不同的存储库)如何判断它们是否都有一些提交。他们只是查看彼此的提交数字。如果数字相同,则提交相同。如果不是,则提交是不同的。所以从某种意义上说,数字是提交,除了数字只是提交的散列,如果你没有数字,你需要得到整个提交(来自拥有它的人)。
-
同时,每个提交存储两件事:
-
每个提交都有一个每个文件的完整快照。更准确地说,每个提交都有它拥有的所有文件的完整快照。这听起来是多余的,但提交 a123456 可能有 10 个文件,而提交 b789abc 可能有 20 个文件,所以显然某些提交可能比另一个提交更多的文件。这样做的重点是要注意,只要您有提交,您就会拥有所有文件的完整快照,就像存档一样。
提交内部的文件以特殊的 Git-only 形式存储。它们被压缩并且——更重要的是——重复数据删除。这可以防止存储库变得非常臃肿:大多数提交主要重用以前提交的文件,但是当他们这样做时,文件都被删除了重复数据,因此新提交几乎不占用任何空间。只有真正不同的文件需要进入;与以前相同的文件只是被重复使用。
-
除了快照之外,每个提交都有一些元数据。元数据只是关于提交本身的信息。这包括诸如提交人的姓名之类的内容。它包括一些日期和时间戳:when 他们做出了提交。它包括一条日志消息,他们在其中说为什么他们做出了提交。
对于 Git 本身至关重要,Git 在此元数据中添加了一个提交编号列表——“哈希 ID”或“对象 ID”(OID)——之前次提交。
大多数提交只存储一个哈希 ID,用于(单数)先前或 父 提交。这种形式提交到链。这些链条向后工作,这是有充分理由的。
1这种完全唯一性的想法在实践中是正确的,但在理论上是不正确的,但只要它在实践中是正确的就可以了。为了使其在实践中发挥作用,这些数字需要尽可能大——或者很快,更大,而 Git 人员现在正在努力让它们变得更大。
每次提交的所有部分都是只读的
为了使提交编号(加密哈希 ID)起作用,Git 需要确保任何提交的任何部分都不能更改。事实上,你可以从 Git all-commits 数据库中取出一个提交,然后用它来更改内容或元数据并将其放回原处,但是当你这样做时,你只会得到一个 新的和不同的提交 em> 具有新的唯一哈希 ID。旧的提交保留在旧 ID 下的数据库中。
因此,提交是由两部分组成的东西——快照和元数据——它是只读的,或多或少是永久的。您真正使用 Git 所做的只是添加更多提交。您实际上无法取出任何东西,2 但很容易添加新的,因为这就是 Git 的初衷。
2但是,您可以停止使用提交,如果提交不仅未使用而且不可找到,Git最终会意识到这个提交是垃圾,并将丢弃它。因此,如果需要,这就是您摆脱提交的方法:您只需确保它们无法找到,然后 Git 最终——这需要一段时间!——将它们扔掉。不过,我们不会在这里详细介绍。
让我们多谈谈父母和反向链的事情
虽然这与您现在正在做的事情无关,但它确实很重要,所以让我们看看提交链是如何工作的。我们已经说过,大多数提交记录了一个较早提交的原始哈希 ID。我们还说过哈希 ID 又大又丑,对人类来说是不可能的(这是真的:e9e5ba39a78c8f5057262d49e261b42a8660d5b9 到底是什么意思?)。因此,假设我们有一个包含一些提交的小型存储库,但我们使用单个大写字母代替它们的真实哈希 ID,而不是这些提交。
我们将从一个只有 三个 提交的存储库开始,我们将其称为 A、B 和 C。 C 将是 最新 提交。让我们把它画进去:
<-C
C 包含早期提交 B 的原始哈希 ID。我们喜欢将它们绘制为从提交中出来的箭头,并说C 指向 B。让我们现在也画B:
<-B <-C
当然B 有这些箭头之一,指向较早的提交A:
A <-B <-C
这是我们完整的提交链。 A,作为第一个提交,没有指向任何更早的东西,因为它不能,所以链在这里停止。
为了添加一个新的提交,我们告诉 Git 用提交 C 做一些事情——我们稍后会更完整地描述这个——然后使用 @ 987654349@ 进行新的提交,然后将指向C:
A <-B <-C <-D
现在我们的链中有 四个 提交,新提交 D 指向 C。
除了这些向后的箭头,每个提交都有一个完整的快照。当我们创建D 时,我们大概更改了一些文件——同样,我们稍后会更详细地讨论这个问题——所以D 中的一些文件与C 中的文件不同.我们大概留下了一些文件。我们现在可以让 Git 向我们展示D 中的更改。
为此,Git 将 both C 和 D 提取到临时区域(在内存中)并检查包含的文件。当他们匹配时,它什么也没说。 Git 执行的重复数据删除使这个测试变得容易,Git 实际上可以完全跳过这些文件的提取。只有对于 不同 的文件,Git 实际上才需要提取它们。然后它比较它们,玩一种Spot the Difference 的游戏,并告诉我们这些更改的文件有什么不同。那是git diff,也是我们从git log -p 或git show 看到的。
当我们在一次提交上运行 git show 时,Git:
- 以某种格式打印元数据或其中的某些选定部分;和
- 运行这种差异来查看这个提交的父级和这个提交之间有什么不同。
当我们运行git log,Git:
- 从最后一次提交开始
D;
- 向我们展示了该提交,如果我们使用
-p,可能也带有git show 样式差异;那么
- 向后移动一跳到上一个提交,
C,然后重复。
只有当我们厌倦了查看git log 输出,或者 Git 到达第一个提交 (A) 时,此过程才会停止。
寻找提交
让我们再画几个提交。我将对提交之间的内部箭头变得懒惰:它们是每个提交的一部分,因此不能改变,所以我们知道它们总是指向后面。我将在这里用哈希 H 结束我的链:
...--F--G--H
一旦我们有 lot 的提交(超过此所暗示的 8 个左右),就很难确定哪个看起来随机的哈希 ID H 实际上有。我们需要一种快速的方法来找到哈希,H。
Git 对此的回答是使用分支名称。分支名称就是任何符合name restrictions 的旧名称。该名称包含 一个 哈希 ID,例如提交 H 的哈希 ID。
给定一个包含提交H 的哈希ID 的名称,我们说这个名称指向 H,并把它画进去:
...--G--H <-- main
如果我们愿意,我们可以有多个名称指向提交H:
...--G--H <-- develop, main
我们现在需要一种方法来了解我们正在使用哪个名称。为此,Git 将一个非常特殊的名称 HEAD 附加到一个分支名称上,像这样全部大写。附有HEAD 的名称是当前分支,该分支名称指向的提交是当前提交。所以:
...--G--H <-- develop, main (HEAD)
我们是on branch main,正如git status 所说,我们正在使用哈希ID 为H 的提交。如果我们运行:
git switch develop
作为一个 Git 命令,它告诉 Git 我们应该停止使用名称 main 并开始使用名称 develop:
...--G--H <-- develop (HEAD), main
当我们这样做时,我们从提交H 移动到...提交H。我们实际上并没有去任何地方。这是一种特殊情况,Git 确保除了更改 HEAD 的附加位置之外什么都不做。
现在我们“开启”了分支develop,让我们进行新 提交。我们不会过多地谈论如何我们现在这样做,但我们会回到那个,因为这是您当前问题的核心。
无论如何,我们将引入我们的new 提交I,它将指向现有的提交H。 Git 知道I 的父级应该是H 因为,当我们开始时,名称develop 选择提交H,因此H 是当前提交 在我们开始整个“make new commit”过程的时候。 最终结果是这样的:
I <-- develop (HEAD)
/
...--G--H <-- main
也就是说,name develop 现在选择提交 I,而不是提交 H。存储库中的其他分支名称没有移动:它们仍然选择之前所做的任何提交。但现在develop 表示提交I。
如果我们再次提交,我们会得到:
I--J <-- develop (HEAD)
/
...--G--H <-- main
即名称develop 现在选择提交J。
如果我们现在运行 git switch main 或 git checkout main——两者都做同样的事情——Git 将删除所有与 J 相关的文件(它们被安全地永久存储在 J 中)并提取 H 附带的所有文件:
I--J <-- develop
/
...--G--H <-- main (HEAD)
我们现在是on branch main,我们又拥有来自H 的文件。如果我们愿意,我们现在可以创建另一个新的分支名称,例如 feature,然后进入 那个 分支:
I--J <-- develop
/
...--G--H <-- feature (HEAD), main
注意通过H 并包括H 的提交是如何在所有三个分支上进行的,而提交I-J 仅在develop 上。当我们进行新的提交时:
I--J <-- develop
/
...--G--H <-- main
\
K--L <-- feature (HEAD)
当前分支名称向前移动,以适应新的提交,并且新的提交仅在当前分支上。我们可以通过移动分支名称来改变这一点:names 移动,即使提交本身是一成不变的。
提交是只读的,那么我们如何编辑文件呢?
我们现在来到您问题的核心部分。我们不——事实上,我们不能——直接处理提交,因为它们是这种奇怪的仅 Git 格式。我们必须让 Gitextract 提交。我们已经看到 git checkout 或 git switch 可以做到这一点,但现在是时候全面了解一下了。
为了完成新工作,Git 为您提供了 Git 所谓的 工作树 或 工作树。这是一个目录(或文件夹,如果您更喜欢该术语),其中包含您计算机的普通文件格式的普通文件。 这些文件不在 Git 中。其中一些来自Git,可以肯定的是:git checkout 或 git switch 进程填写 你的工作树。但它通过这个过程做到了:
- 首先,如果您签出了一些现有的提交,Git 需要删除所有来自该提交的文件。
- 然后,由于您将 移动到 一些 other 提交,Git 现在需要 创建(新鲜)存储在 那个提交。
所以Git会根据两次提交的不同,删除旧文件并放入新文件。
但是你的工作树是一个普通的目录/文件夹。这意味着 您 可以在这里创建文件,或在此处更改文件的内容,而 Git 对此过程没有任何控制或影响。您创建的一些文件将是全新的:它们不在 Git 中,它们不是来自 Git,Git 从未见过它们。其他文件实际上可能在很久以前的某个旧提交中,但不是来自 this 提交。一些文件确实来自这个提交。
当您使用git status 时,Git 需要比较您的工作树中的内容与某些内容。现在这个过程变得有点复杂了,因为 Git 实际上并没有从你的工作树中的文件进行 new 提交。3 相反,Git 保留了另一个 所有文件的副本。
请记住,已提交的文件(当前或 HEAD 提交中的文件)是只读的,并且采用 Git 化的、去重复的格式,只有 Git 本身可以读取。因此,Git 将这些文件提取为普通文件,为每个文件留下 两个 个副本:
- 提交中的 Git-only 只读文件,并且
- 工作树中的那个。
但实际上,Git 偷偷地在这两个副本之间插入了一个副本,这样每个文件就有三个 个副本:
-
HEAD 中有 Git 化的,无法更改;
- 在中间位置有一个 Git 化的准备提交副本;和
- 您的工作树中有一个可用副本。
因此,如果您有一些像 README.md 和 main.py 这样的文件,实际上每个文件都有三个副本。中间那个在 Git 调用的地方,不同的地方是 index,或 staging area,或 cache。这个东西一共有三个名字,可能是因为index这个名字太差劲了,cache也不好。 staging area 这个词可能是最好的词,但我会在这里使用 index,因为它更短且无意义,有时无意义也不错。 ?
那么,我们的文件的三个副本是:
HEAD index work-tree
--------- --------- ---------
README.md README.md README.md
main.py main.py main.py
Git 的 index 中的文件是 Git 将 提交 的文件。因此,我想说的是,Git 的索引是您提议的下一次提交。
当 Git 第一次提取提交时,Git both 它的索引 和 你的工作树。 in Git 索引中的文件是预压缩和预去重的。由于它们来自提交,它们都是自动重复的,因此不占用空间。4工作树中的那些确实占用空间,但你需要那些因为你必须让它们去 Git 化才能使用它们。
当您修改工作树中的文件时,不会发生任何其他事情:Git 的索引没有改变。提交本身当然没有改变:它实际上不能被改变。但是索引中的文件也没有发生任何事情。
一旦您进行了一些更改并希望提交这些更改,您必须告诉 Git:嘿,Git,将旧版本的文件从索引中踢出。阅读我的工作树版本main.py,因为我改变了它!立即将其压缩为您的内部压缩格式! 您可以使用 git add main.py 进行此操作。 Git 读取并压缩文件,并检查结果是否重复。
如果结果是重复,Git 会踢出当前的main.py 并使用新的重复。如果结果不是重复,保存压缩文件以便准备好提交,然后做同样的事情:踢出当前的main.py并放入在文件的现在去重(但第一次出现)副本中。所以不管怎样,索引现在已经更新并准备好了。
因此,索引随时准备提交。如果您修改某些现有文件,则必须git add:这会通过更新索引来压缩、消除重复和准备提交。如果你创建一个全新的文件,你必须git add:这会压缩、去重复和准备提交。通过更新 Git 的索引,您可以准备好提交的文件。
这也是您删除文件的方式。它保留在当前提交中,但如果你使用git rm,Git 将同时删除索引副本和工作树副本:
git rm main.py
产生:
HEAD index work-tree
--------- --------- ---------
README.md README.md README.md
main.py
您所做的下一个提交不会有main.py。
3这实际上很奇怪:大多数非 Git 版本控制系统确实使用您的工作树来保存提议的下一次提交。
4索引条目本身会占用一些空间,通常每个文件大约 100 个字节或略低于 100 个字节,用于保存文件名、内部 Git 哈希 ID 和其他使 Git 有用的东西快。
现在我们看看git commit 是如何工作的
当你运行git commit,Git:
- 收集任何需要的元数据,例如来自
git config 的user.name 和user.email,以及进入新提交的日志消息;
-
当前提交的哈希 ID 是新提交的父级;
- Git 的 index 中的任何内容都是 snapshot,因此 Git 将索引冻结为新的快照;和
- Git 写出快照和元数据,获取新提交的哈希 ID。
在您运行 git commit 之前,我们不知道哈希 ID 是什么,因为进入元数据的部分内容是当时的 当前日期和时间,而我们不知道'不知道什么时候你会做出那个提交。所以我们永远不知道任何 future 提交哈希 ID 会是什么。但我们确实知道,因为它们都是一成不变的,所有过去的提交哈希 ID 是什么。
所以现在 Git 可以写出 commit I:
I
/
...--G--H <-- develop (HEAD), main
一旦 Git 将其写出并获得哈希 ID,Git 可以将该哈希 ID 填充到 分支名称 develop,因为这是附加 HEAD 的位置:
I <-- develop (HEAD)
/
...--G--H <-- main
这就是我们分支的成长方式。
索引,或暂存区,决定了下一次提交的内容。您的工作树允许您编辑文件,以便您可以git add 将它们放入 Git 的索引中。 checkout 或 switch 命令从索引中删除 当前提交的 文件,然后转到 chosen 提交,填写 Git 的索引和您的工作树,并选择哪个分支名称-and-commit 将成为 新的当前提交。这些文件从那个提交中输出并填写 Git 的索引和您的工作树,然后您就可以再次工作了。
但是,直到您真正运行 git commit,您的文件才 在 Git 中。一旦你运行git add,它们就在Git 的index 中,但这只是一个临时存储区域,将被下一个git checkout 或git switch 覆盖。真正拯救他们的是git commit 步骤。这也将新提交添加到 当前分支。
介绍其他 Git 存储库
现在,除了上述所有内容之外,您还使用了git fetch。当至少有 两个 Git 存储库 时,您可以使用它。我们之前提到过,我们将使用两个存储库将两个 Git(Git 软件的两个实现)相互连接并让它们传输提交。一个 Git 可以通过显示哈希 ID 来判断另一个 Git 是否有提交:另一个 Git 在其所有提交的大数据库中有那个提交,或者没有。如果缺少提交的 Git 说 我没有那个,给我,那么 发送 Git 必须打包该提交 - 以及任何所需的支持对象 - 并且将它们发送过来,现在 receiving Git 也有该提交。
我们在这里总是使用单向传输:我们运行git fetch 来get 来自其他 Git 的提交,或者运行 git push 来发送 提交 em> 其他一些 Git。这两个操作(获取和推送)与 Git 的对立一样接近,尽管这里存在某种根本的不匹配(我不会讨论,因为这已经很长了)。我们只谈fetch。
当我们将我们的 Git 连接到其他 Git 时——让我们在这里使用 GitHub 的 Git 软件和存储库作为我们的示例,尽管使用正确的 Git 软件协议的任何东西都可以使用 git fetch,我们:
-
让另一个 Git 列出它的所有分支(和标签)名称以及与这些分支名称相关的提交哈希 ID(标签使事情变得更复杂,所以我们将在这里忽略它们)。
-
对于我们没有但感兴趣的每个提交哈希ID——我们可以在这里限制我们打扰的分支名称,但默认是全部 很有趣——我们要求他们请发送该提交!。他们现在有义务提供这些提交的 parent 提交。我们检查我们是否有 那些 提交,如果没有,也询问那些。这种情况一直持续到他们得到我们确实拥有的提交,或者完全用完提交。
-
这样,我们将从他们那里获得他们拥有的每一个我们没有的提交。然后,他们将这些与任何所需的支持内部对象一起打包,并将它们全部发送出去。现在我们有了他们所有的提交!
-
但还记得我们是如何find 在我们的存储库中使用branch 名称提交的吗?我们现在有一个问题。
假设我们的存储库中有这些提交:
...--G--H--I <-- main (HEAD)
也就是说,我们只有一个分支名称,main。我们之前通过H 从他们那里获得提交,但后来我们自己提交了I。
与此同时,当我们提交 I 时,他们提交了 J 并将其放在 他们的 main 上,所以 他们有:
...--G--H
\
J <-- main (HEAD)
我用J 画了这个,因为当我们结合我们的提交和他们的提交时,我们最终得到:
...--G--H--I <-- main (HEAD)
\
J
我们将附加什么 name 来提交 J 以便能够找到它? (请记住,它的真实名称是一些大而丑陋的随机哈希 ID。)他们正在使用名为main 的他们的 分支来找到它,但是如果我们移动 我们的分支main指向J,我们将失去我们自己的I!
所以我们不会更新我们的任何分支名称。相反,我们的 Git 将为它们的每个 branch 名称创建或更新一个 remote-tracking 名称:
...--G--H--I <-- main (HEAD)
\
J <-- origin/main
我们的远程跟踪名称显示为git branch -r,或git branch -a(显示我们自己的分支名称和我们的远程跟踪名称)。远程跟踪名称只是我们的 Git 记住他们的分支名称的方式,我们的 Git 通过在他们的分支名称前面粘贴 origin/ 来弥补它。5
现在我们有他们的提交和我们的提交,加上远程跟踪名称,如果他们不完全重叠我们的提交,可以帮助我们找到他们的提交,现在我们 他们的提交可以做一些事情。我们所做的“某事”取决于我们想要完成的事情,而这里的事情实际上开始变得复杂——所以我会在这里停下来。
5从技术上讲,我们的远程跟踪名称位于单独的 namespace 中,因此即使我们做一些疯狂的事情,例如创建一个名为 origin/hello 的(本地)分支,Git 将保持这些直。但是不要这样做:即使使用 Git 为不同名称着色的技巧,您也可能会感到困惑。
那么您的更改发生了什么?
让我们再看看这部分:
$ git checkout A
error: The following untracked working tree files would be overwritten by checkout:
cc.py dd.py ....
这些是您创建的文件,并非来自之前的提交。它们在您的工作树中,但不在 Git 中。 (“未跟踪”的意思是“甚至不在 Git 的索引中”。)
checkout 命令给了你这个错误,让你可以在 Git 中(通过添加和提交文件)或其他地方保存文件。但你没有提到这样做:
$ git checkout -f A
-f,或--force,这里的标志表示继续,覆盖这些文件。所以你创建的文件 消失了:分支名称A 选择了一个包含这些文件的提交,所以它们从提交中出来,进入 Git 的索引,并被扩展到你的工作树中。
以前的工作树文件从未在 Git 中,因此 Git 无法检索它们。如果您有其他方法来检索它们 - 例如,如果您的编辑器保存备份 - 使用它。如果没有,你可能会倒霉。