首先,请注意index 和staging area 这两个术语的含义相同。还有第三个术语,cache,现在主要出现在标志中(例如git rm --cached)。这些都指向同一个基础实体。
接下来,尽管考虑更改通常很方便,但这最终会误导您,除非您牢记这一点:Git 不存储更改,而是快照。当我们比较两个快照时,我们只会看到变化。我们将它们并排放置,就好像我们在玩Spot the Difference 的游戏一样——或者更准确地说,我们让 Git 将它们并排放置并比较它们并告诉我们有什么不同。所以现在我们看到了这两个快照之间的变化。但Git 没有这些变化。它有两个快照,只是比较它们。
现在我们进入真正棘手的部分。我们知道:
所以提交存储快照,Git 可以提取这些快照供我们处理。但是 Git 并没有只是将提交提取到工作区。其他版本控制系统做:他们有提交和工作树,这就是全部,以及您需要了解的所有内容。提交的版本一直被冻结,可用的版本是可用的,并且是可变的。这是两个“活动”版本,为我们提供了一种查看更改内容的方法:只需将活动但冻结的快照与工作快照进行比较。
但无论出于何种原因,Git 都没有这样做。相反,Git 有 三个 活动版本。一个活动版本一直被冻结,就像往常一样。一个活动版本在您的工作树中,就像往常一样。但是在这两个版本之间,还有第三个快照。它是可变的,但它更像是冻结的副本而不是有用的副本。
每个文件的第三个副本,位于冻结提交和可用副本之间,是 Git 的索引,或者至少是您需要担心的 Git 索引的一部分。1 你需要了解 Git 的索引,因为它是你提议的下一次提交。
也就是说,当你运行时:
git commit
Git 会做的是:
- 收集适当的元数据,包括当前提交的哈希 ID;
- 制作一个新的(虽然不一定是唯一的2)快照;
- 使用快照和元数据进行新的、唯一的提交;3
- 将新提交的哈希 ID 写入 当前分支名称。
这里的最后一步将新的提交添加到当前分支。上面第 2 步中的快照是 此时 Git 索引中的任何内容。所以在你运行git commit之前,你必须更新Git的索引。这就是 Git 让你运行 git add 的原因,即使对于 Git 已经知道的文件:你并没有完全添加文件。相反,您覆盖了索引副本。
1剩下的部分是 Git 的缓存,通常不会在你面前全部显示出来。您可以在不了解缓存方面的情况下使用 Git。在不知道索引的情况下很好地使用 Git 是很困难的——也许是不可能的。
2如果你做了一个提交,然后恢复它,second 提交会重新使用你在 first 之前的快照> 提交,例如。重用旧快照一点也不异常。
3与源快照不同,每次提交始终是唯一的。了解为什么会出现这种情况的一种方法是每次提交都有一个日期和时间。您必须在一秒钟内进行多次提交,以免它们中的任何一个获得相同的时间戳。即使这样,这些提交也可能具有不同的快照和/或不同的父提交哈希 ID,这会使它们保持不同。获得 same 哈希 ID 的唯一方法是在相同的上一次提交之后,由同一个人同时提交相同的源。4
4或者,您可能会遇到哈希 ID 冲突,但这实际上从未发生过。另见How does the newly found SHA-1 collision affect Git?
一张图片
让我们绘制一些提交的图片。让我们使用大写字母代替哈希 ID。我们将沿着主线分支有一个简单的提交链,还没有其他分支:
... <-F <-G <-H
这里,H 代表链中 last 提交的哈希 ID。提交H 既有快照(无论何时你或任何人提交H,都会从Git 的索引中保存)和元数据(提交H 的人的姓名等)。在元数据中,commit H 存储了之前的 commit G 的原始哈希 ID。所以我们说H 指向 G。
Commit G 当然也有快照和元数据。该元数据使较早的提交G 指向更早的提交F。提交F 反过来又指向更远的地方。
这一直重复到第一次提交。作为第一,它不指向回,因为它不能;所以 Git 可以停在这里。 Git 只需要能够找到 last 提交。 Git 需要它的哈希 ID。你可以自己输入,但那会很痛苦。您可以将其存储在某个文件中,但这会很烦人。您可以让 Git 为您存储它,这样会很方便 — 这正是分支名称的意义所在:
...--F--G--H <-- main
namemain 只是保存链中最后次提交的一个哈希 ID。
无论我们有多少名称和提交,这都是正确的:每个名称都包含一些实际有效提交的哈希 ID。让我们创建一个新名称feature,它也指向H,如下所示:
...--F--G--H <-- feature, main
现在我们需要一种方法来知道我们正在使用哪个 name。 Git 将特殊名称 HEAD 附加到分支名称之一,如下所示:
...--F--G--H <-- feature, main (HEAD)
我们现在“开启”main,使用提交H。让我们使用git switch 或git checkout 切换到名称 feature:
...--F--G--H <-- feature (HEAD), main
没有其他变化:我们仍在使用提交H。但是我们使用它是因为 name feature。
如果我们进行新的提交——我们称之为提交I——提交I将指向提交H,Git会将提交I的哈希ID写入当前的名称。这将产生:
...--F--G--H <-- main
\
I <-- feature (HEAD)
现在如果我们git checkout main,Git 必须换掉我们的工作树内容和我们建议的下一个提交内容。所以git checkout main 将翻转 Git 的索引 和 我们的工作树内容,以便它们匹配提交 H。之后,git checkout feature 会将它们翻转回来,以便它们都匹配提交 I。
如果我们在feature 上进行新的提交J,我们会得到:
...--F--G--H <-- main
\
I--J <-- feature (HEAD)
reset 命令:很复杂!
git reset 命令很复杂。5 我们将在这里只查看命令的“完整提交”重置变体——使用--hard、--soft 和@ 的命令987654372@ 选项——而不是那些我们现在可以在 Git 2.23 及更高版本中使用 git restore 做的事情。
这些“整体提交”重置操作采用一般形式:
git reset [<mode-flag>] [<commit>]
mode-flag 是 --soft、--mixed 或 --hard 之一。6commit 说明符— 它可以是直接的原始哈希 ID,也可以是任何其他可以转换为提交哈希 ID 的东西,通过将其提供给 git rev-parse — 告诉我们将移动到哪个提交。
这个命令做了三件事,除了你可以让它提前停止:
-
首先,它移动HEAD 所附加的分支名称。7 它只需将新的哈希 ID 写入分支名称即可。
-
其次,它将 Git 索引中的内容替换为您选择的提交中的内容。
-
第三个也是最后一个,它将工作树中的内容替换为 Git 索引中的内容。
第一部分——移动HEAD——总是发生,但是如果你选择当前提交作为新的哈希ID,“移动”就是你从哪里开始的是,你在哪里:有点毫无意义。仅当您让命令继续执行第 2 步和第 3 步,或至少执行第 2 步时,这才有意义。但它总是会发生。
commit 的 默认值 是 当前提交。也就是说,如果您不选择新的提交,git reset 将选择 当前提交 作为移动 HEAD 的位置。因此,如果您不选择新的提交,那么您将在第 1 步中执行“原地不动”的动作。没关系,只要你不让它停在那里:如果你在第 1 步之后让git reset 停止,并让它留在原地,你要做很多工作来完成什么都没有。这并没有真正错,但这是在浪费时间。
那么,现在让我们看看标志:
-
--soft 告诉git reset:移动,然后停在那里。无论之前在 Git 的索引中是什么,移动之后仍然在 Git 的索引中。工作树中的任何内容都不会受到影响。
-
--mixed 告诉git reset:执行此操作,然后覆盖您的索引,但不要管我的工作树。
-
--hard 告诉git reset:移动,然后覆盖你的索引和我的工作树。
所以,假设我们从这个开始:
...--F--G--H <-- main
\
I--J <-- feature (HEAD)
并选择提交I 作为git reset 应该移动feature 的位置,因此我们最终得到:
...--F--G--H <-- main
\
I <-- feature (HEAD)
\
J
注意提交J 仍然存在,但除非我们将哈希ID 保存在某处,否则我们无法找到它。我们可以将J 的哈希 ID 保存在纸上、白板上、文件中、另一个分支名称中、标签名称中或其他任何地方。任何可以让我们输入或剪切和粘贴的东西,或者任何可以做的事情。然后我们可以创建一个找到J 的新名称。我们可以在执行git reset 之前在执行此操作,例如:
git branch save
git reset --mixed <hash-of-I>
会得到我们:
...--F--G--H <-- main
\
I <-- feature (HEAD)
\
J <-- save
名称save 保留J 的哈希ID。
--mixed,如果我们在这里使用它,它会告诉 Git:根本不要碰我的工作树文件!这并不意味着你会在你的工作中——树,与提交J 中的文件完全相同,因为也许您在执行git reset 之前正在摆弄那些工作树文件。 --mixed 表示 Git 将使用来自 I 的文件覆盖 Git 索引中的 its 文件。但 Git 不会在此处接触 您的 文件。只有--hard 将git reset 触摸您的 文件。
(当然,如果您运行git checkout 或git switch:那么,那些 命令 也应该触及您的 文件,所以这又变得更复杂了。但现在不要担心,因为我们专注于git reset。)
5我个人认为git reset 太复杂了,就像git checkout 一样。 Git 2.23 将旧的git checkout 拆分为git switch 和git restore。我认为git reset 应该同样分开。但现在还没有,所以除了写这个脚注之外,没有什么抱怨的意义。
6还有--merge 和--keep 模式,但它们只是更复杂的情况,我也打算忽略。
7在 detached HEAD 模式下,我在这里忽略它,它只是将一个新的哈希 ID 直接写入HEAD。
总结
git reset 的 默认值 是保留您的文件 (--mixed)。你也可以告诉 Git 不理会它自己的索引,--soft:当你想要使用 Git 索引中的内容进行新提交时,这有时很有用。假设你有:
...--G--H <-- main
\
I--J--K--L--M--N--O--P--Q--R <-- feature (HEAD)
I 到 Q 的提交所有只是各种实验,而您的最后一次提交 - 提交 R - 具有最终形状。
然后,假设您希望使用来自R 的快照进行一个新 提交,但在提交I 之后,并且您想要在你的(更新的)feature 上调用 last 提交。你可以这样做:
git checkout feature # if necessary - if you're not already there
git status # make sure commit R is healthy, etc
git reset --soft main # move the branch name but leave everything else
git commit
在git reset之后,我们有这张照片:
...--G--H <-- feature (HEAD), main
\
I--J--K--L--M--N--O--P--Q--R ???
现在很难找到I 到R 的提交。但是正确的文件现在在 Git 的 index 中,可以提交了,所以 git commit 进行了一个新的提交,我们可以称之为 S(对于“squash” ):
S <-- feature (HEAD)
/
...--G--H <-- main
\
I--J--K--L--M--N--O--P--Q--R ???
如果您将R 中的快照与S 中的快照进行比较,它们将是相同的。 (这是另一种情况,Git 只会重用现有的快照。)但是由于我们无法看到提交I-J-...-R,现在似乎我们已经神奇地将所有提交压缩为一个:
S <-- feature (HEAD)
/
...--G--H <-- main
比较S 和它的父H,我们看到所有相同的变化,就像我们比较H 和R 一样。如果我们再也见不到I-J-...-R,那可能就好了!
所以git reset --soft 很方便,因为我们可以移动分支名称并在 Git 的索引和我们的工作树中保留 所有内容。
在其他一些情况下,我们可能希望从R 中的文件中进行两次 次提交。这里我们可以让--mixed重置Git的索引:
git reset main
git add <subset-of-files>
git commit
git add <rest-of-files>
git commit
这会给我们:
S--T <-- feature (HEAD)
/
...--G--H <-- main
T 中的快照与R 中的快照匹配,S 中的快照只有一些更改的文件。在这里,我们使用--mixed 重置模式来保持工作树中的所有文件完好无损,但重置 Git 的索引。然后我们使用git add 更新Git 的索引以匹配我们工作树的部分,提交一次以生成S,并使用git add 更新rest我们的工作树并再次提交以生成T。
所以所有这些模式都有它们的用途,但要理解这些用途,您需要了解 Git 对 Git 的索引和您的工作树做了什么。