我提交了一些更改,其中包含我不想提交的更改,因此我想删除该提交,但保留已提交的暂存和未暂存更改,以便我可以在提交之前删除不需要的更改。我使用了git reset --hard <hash>
但它恢复为 HEAD - 1 的提交,它不仅删除了提交,还删除了提交之前的所有暂存和未暂存更改。
有没有办法重置为提交,而是将所有提交的更改(转储回)到工作树中,而不是删除该提交中记录的每个更改?换句话说,如何将所有提交的更改返回到工作树?
首先,请注意,术语索引和暂存区域的含义相同。 还有第三个术语,缓存,现在主要出现在标志中(例如git rm --cached
)。 这些都指同一个基础实体。
接下来,从变化的角度思考通常很方便,这最终会误导你,除非你牢牢记这一点:Git 不存储更改,而是存储快照。 我们只有在比较两个快照时才能看到变化。 我们把它们并排放置,就好像我们在玩一个发现差异的游戏——或者更准确地说,我们让 Git 将它们并排放置并进行比较并告诉我们有什么不同。 所以现在我们看到了这两个快照之间的变化。 但是 Git没有这些变化。 它有两个快照,只是比较它们。
现在我们进入真正棘手的部分。 我们知道:
-
每个提交都有一个唯一的哈希 ID,这就是 Git 找到该特定提交的方式;
-
每个提交存储两件事:
- 它有一个完整的快照,包括 Git 在你或任何人制作快照时所知道的每个文件;和
- 它有一些元数据,包括提交者的姓名和电子邮件地址、一些日期和时间戳等——对于 Git 来说,重要的是,它有一些早期提交的原始哈希 ID,以便 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例如,如果您进行提交,然后还原它,则第二次提交会重用您在进行第一次提交之前拥有的快照。 最终重用旧快照一点也不异常。
3与源快照不同,每个提交始终是唯一的。 了解为什么会这样的一种方法是每次提交都会获得一个日期和时间。 您必须在一秒钟内进行多次提交,以免其中任何一个获得相同的时间戳。 即便如此,这些提交也可能具有不同的快照和/或不同的父提交哈希 ID,这将使它们保持不同。 获得相同哈希 ID 的唯一方法是在相同的上一次提交之后,由同一个人同时提交相同的源。四
4或者,您可能会遇到哈希 ID 冲突,但这实际上从未发生过。 参见 新发现的 SHA-1 冲突如何影响 Git?
<小时 />一张图片
让我们绘制一些提交的图片。 让我们使用大写字母代替哈希 ID。 我们将在主线分支上有一个简单的提交链,目前还没有其他分支:
... <-F <-G <-H
在这里,H
代表链中最后一个提交的哈希 ID。 提交H
既有快照(只要你或任何人提交H
,就会从 Git 的索引中保存)和元数据(制作H
的人的姓名等)。 在元数据中,提交H
存储较早的提交G
的原始哈希 ID。 所以我们说H
指向G
.
当然,提交G
也有快照和元数据。 该元数据使较早的提交G
指向较早的提交F
。 提交F
反过来指向更远的地方。
这一直重复到有史以来第一次提交。 作为第一个,它不会指向后退,因为它不能;所以 Git 可以停在这里。 Git 只需要能够找到最后一个提交。 Git 需要它的哈希 ID。 你可以自己输入它,但那会很痛苦。 您可以将其存储在某个文件中,但这很烦人。 你可以让Git为你存储它,这会很方便——这就是分支名称对你的作用:
...--F--G--H <-- main
名称main
仅包含链中最后一个提交的一个哈希 ID。
无论我们有多少个名称和提交,都是如此:每个名称都包含一些实际有效提交的哈希 ID。 让我们创建一个新名称,feature
,它也指向H
,如下所示:
...--F--G--H <-- feature, main
现在我们需要一种方法来知道我们使用的是哪个名称。 Git 将特殊名称HEAD
附加到其中一个分支名称,如下所示:
...--F--G--H <-- feature, main (HEAD)
我们现在"打开"main
,并使用提交H
。 让我们使用git switch
或git checkout
切换到名称feature
:
...--F--G--H <-- feature (HEAD), main
其他一切都没有改变:我们仍在使用提交H
。 但是我们使用它是因为名称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
和--mixed
选项的命令 - 而不是那些主要执行我们现在可以使用 Git 2.23 及更高版本中的git restore
执行的操作的命令。
这些"完整提交"重置操作采用一般形式:
git reset [<mode-flag>] [<commit>]
mode-flag
是--soft
、--mixed
或--hard
之一。6commit
说明符(可以直接是原始哈希 ID,也可以是可以通过将其提供给git rev-parse
来转换为提交哈希 ID 的任何其他内容)告诉我们将移动到哪个提交。
该命令执行三项操作,但您可以让它提前停止:
首先,它移动
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 索引中的文件。 但是 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在分离的 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
上的最后一次提交。 您可以使用以下方法执行此操作:
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 ???
现在很难找到通过R
I
提交。 但是正确的文件现在在 Git 的索引中,可以提交了,所以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
更新工作树的其余部分并再次提交以创建T
。
所以所有这些模式都有其用途,但要理解这些用途,你需要了解 Git 对 Git 的索引和你的工作树做了什么。
简短的回答:我会使用git stash
。
长答案:运行git stash
将通过撤消您对它们所做的任何更改来重置工作目录和当前头部的索引。它以与提交非常相似的形式在stash
中存储这些更改的记录。
如果此时运行git status
,它应该显示没有更改。(未跟踪的文件仍会显示。git stash
对未跟踪的文件没有影响。
然后,您可以对提交历史记录进行任何更改,也许使用git reset
或git rebase
。完成后,运行git stash pop
.更改将从存储中检索,并重新应用于索引和工作目录。
您甚至可以在一个分支上运行git stash
,然后在运行git stash pop
之前切换到另一个分支。如果您意识到自己一直在错误的分支上工作,这将非常有用。
前面的答案指出 git 存储文件的快照而不是存储更改。但很多时候它的行为好像相反:好像它存储了更改而不是快照,这就是git stash pop
的行为方式:它试图合并更改,而不是简单地用另一个版本覆盖文件的一个版本。请注意,这意味着您在运行git stash pop
时可能会出现合并冲突,就像运行git cherry-pick
或git rebase
时一样。