Git 如何撤销某个文件的修改

3
我对 Git 不熟悉,有一个关于使用 "git reset --hard hexid" 命令撤销更改的问题。例如,我在处理两个文件 fileA 和 fileB,我的工作流程可能如下:
1. 修改 fileA -> 提交 fileA 2. 修改 fileB -> 提交 fileB
现在假设我已经满意于在文件 B 中所做的更改,但实际上我发现我不需要更改 A,希望将其恢复为以前的版本。Git 似乎会对整个工作目录进行快照,所以 "git reset --hard hexid_of_step1" 命令会将两个文件都还原到原始状态。有没有办法只舍弃文件 A 的更改,同时保留文件 B 的更改呢?

你对文件A和文件B的更改是在不同的提交中,还是在同一个提交中? - TTT
@TTT 他们在两个不同的提交中。 - catfield
2个回答

3

TL;DR

使用:

git restore --source=<hash-id> --worktree --staged -- fileA

(假设使用Git 2.23或更高版本)。

在你的第二点中,当你说“Git似乎会对整个工作目录进行快照”时,你已经走在了正确的轨道上。Git确实会拍摄快照。但这里并不是指工作树

首先,让我们注意一下:Git不是关于文件的,而是关于提交的。Git也不是关于分支的:它是关于提交的。提交包含文件,所以我们确实可以保存文件。像mainmaster这样的分支名称帮助我们找到提交。但是真正重要的是提交。因此,我们需要知道提交是什么,它有什么作用,以及如何找到它。

正如你也看到的那样,每个提交都有一个唯一的哈希ID,表示为一个巨大、丑陋、难以记忆的十六进制数字。这个数字是Git找到提交的最低级别方法。如果你有某个特定的提交,它就有那个数字,无论你是如何得到它的,将该数字提供给Git将使你访问该提交。(如果你没有那个提交,Git会说它不认识这个数字;你将不得不将你的Git连接到其他拥有该提交的Git上,并从他们那里获取它。)

大多数情况下,我们不能真正使用这些数字,但我们会记住它们是Git查找提交的底层方法。现在让我们看看提交中有什么。每个提交都包含两个东西:

  • 提交保存了Git知道的每个文件的完整快照,形式为您(或其他人)进行提交时的形式。我们稍后会再次讨论这个问题。

  • 另外,提交还保存了一些元数据,或者关于该提交的信息。元数据包括制作提交的人的姓名和电子邮件地址。有两个这样的元数据(作者和提交者),以及两个日期和时间戳。有一个日志消息的空间,在其中解释您为什么做出了提交。对于Git自己的操作至关重要,每个提交都存储了先前提交哈希ID的列表。通常,这个列表只有一个条目:这个提交的(单一)

每当我们有一个现有提交的哈希ID时,我们就说我们正在“指向”该特定提交。因此,每个提交通常都向后指向一个先前的提交。这形成了一个向后看的链:

... <-F <-G <-H

其中,H 代表链中最后一次提交的哈希 ID。提交 H 包含一个快照和一些元数据——制作者、时间、原因等等——而这些元数据包括较早提交 G 的哈希 ID。Git 可以使用 G 的哈希 ID 从该先前提交检索相同的内容。

比较两个快照可以告诉我们——或者告诉 Git——发生了什么变化。如果我们查看提交记录,通常会看到这一点,例如使用 git log -pgit show。实际上,每个提交都包含自己的完整快照,但我们只会看到发生了变化的文件。当然,在通过比较提交 H 和提交 G 来展示提交 H 后,git log 现在可以向后跳一步到提交 G 本身。Git 现在可以从 F 检索快照,并因此为我们展示提交 G 的更改。从这里,Git 可以向后跳一步到提交 F,以此类推。

所有这些都需要一件事:Git 需要知道链中最后一个提交 H 的哈希 ID。这就是分支名称发挥作用的地方:像 mainmaster 这样的名称仅仅保存链中最后一个或者说是“tip”提交的哈希 ID。即使有更新的提交,这也是正确的,这就是我们看到“分支”的地方:

...--G--H   <-- main
         \
          I--J   <-- feature

在这里,namefeature让Git定位提交J。提交J向后指向提交I,后者向后指向H,以此类推。
考虑到所有这些,这是你问题的直接答案:
“有没有办法仅丢弃对fileA的更改而保留对fileB的更改?”
我们需要做的是从早期的快照中提取一个文件。
在Git 2.23或更高版本中,执行此操作的最佳命令(无论如何)是git restore。在早期的Git版本中,执行此操作的命令是git checkout。在Git 2.23及更高版本中,git checkout仍然有效;只是git checkout用于许多事情,而git restore用于较少的事情,因此git restore是一种更专注的工具。
我们使用这两个命令中的任何一个(或者如果我们的头脑仍然停留在Git 1.7上,则只需使用git checkout,因为我经常遇到这种情况:-))来提取我们关心的一个文件。
git checkout <hash-id> -- fileA

或者:

git restore --source=<hash-id> --worktree --staged -- fileA

Git的索引和工作树

关于Git提交,或者说任何Git对象(提交是由Git的三个内部对象组成的)还有一件重要的事情要知道:它们完全、彻底、100%只读。即使是Git本身也不能改变一次提交。因为物理上不可能改变提交,所以你实际上并不直接处理或使用提交。存储在提交中的文件以特殊的、仅供Git使用的压缩和去重形式保留。这对Git有很多好处:例如,如果它们相同,它使得很容易看到哪些文件在任意一对提交之间是相同的,因为它们被去重了。这也意味着,无论有多少提交保持某个特定版本的100兆字节文件,现实中只有一个存储版本。

无论如何,为了使用提交,Git必须将该提交提取到您的工作树中。这是git checkout(或在Git 2.23或更高版本中的git switch)的主要功能之一:它将从某个提交中取出所有文件,将它们扩展为有用的形式,以填充您的工作树。

同时,这种类型的git checkout(或git switch)还执行了两个操作:它将特殊名称HEAD附加到某个分支名称上,并从该提交中填充Git的索引。当创建新提交时,附加HEAD是很重要的。Git的索引也是如此。

索引(也称为暂存区,有时称为缓存(现在很少用,主要用于像git rm --cached这样的标志中))是Git中的一个关键数据结构。索引所持有的内容在一些角落和扩展情况下变得复杂,但在大多数情况下,可以简单地描述为保存您的建议的下一个提交,或者至少是其快照。(所有下一个提交的元数据都是在您进行下一个提交时实时生成的。)

换句话说,这些快照是从Git的索引中获取的,而不是从您的工作树中获取的。这就是Git让你反复运行git add的原因。每次git add实际上都是对Git的指令:使用我在这个文件/这些文件中拥有的任何内容,并更新你的索引以匹配。

在Git索引中,每个文件都有一个额外的“副本”,实际上已经以压缩和去重的形式存在,因此最初至少不需要真正的额外空间。当您修改工作树文件并使用“git add”添加它们时,Git必须压缩这些文件并将其转换为准备好存储的内部blob对象,这会占用一些空间,但这使得稍后进行的“git commit”非常快:所有内容都已经处于可提交的形式。缺点是您必须了解Git的索引。
当文件存在于Git的索引中时(例如,在“git checkout”或“git switch”之后),因为Git将提交提取到Git的索引和您的工作树中,该文件称为已跟踪文件。仅存在于您的工作树中而不在Git索引中的文件称为未跟踪文件。
运行“git add”通常会将未跟踪文件复制到Git的索引中(作为新条目),然后现在已经被跟踪。运行“git rm”会将文件从Git的索引和您的工作树中删除;运行“git rm --cached”会将文件从Git的索引中删除,而不会影响您的工作树,现在该文件未被跟踪。因此,任何文件的跟踪状态都在您的控制范围内,但请记住,“git checkout”提交具有该文件时,该文件会进入索引/暂存区和您的工作树中。
当您使用“git checkout hash -- file”时,Git将该文件复制到两个位置。当您使用新式的“git restore”时,您可以选择将其复制到索引/暂存区、您的工作树或两者都复制。因此,这是另一种“git restore”更好或至少不同的方式。
请注意,“git checkout”基本上被拆分为“git switch”(更改分支/完全检出)和“git restore”(单个文件),因此这真正是使用旧样式命令和新样式命令之间的主要区别。但是,在Git 2.23之前的Git中,“git checkout”存在一个讨厌的“功能”/缺陷:
git checkout xyzzy

可能意味着以下两种情况之一:

git checkout xyzzy --    # check out the branch

或者:

git checkout -- xyzzy    # restore the file

有时候不清楚哪一个命令会被执行,或者在模糊的情况下你想要哪个命令。从2.23版本开始,git checkout现在注意到这种情况并要求澄清;git switchgit restore是单独的命令,已经明确了你需要的命令。

这里还有一件事要轻轻地提一下,那就是.gitignore文件。这个文件的名称是错误的:它并不意味着“忽略这些文件”。相反,它的意思是:“如果这些文件未被跟踪,请不要抱怨它们未被跟踪,并且不要自动将它们添加到Git的索引中,以便它们变成已跟踪文件。”但是,一旦某个文件被跟踪,将该文件列在.gitignore中就没有任何效果。

命名提交

正如你无疑所见,通过哈希ID来命名提交很麻烦。运行git log并使用剪切和粘贴功能可以正常工作(我自己也经常这样做),但是有许多有用的命名提交的方法。这些在gitrevisions手册页中有记录。它相当复杂,值得随着时间的推移经常重新阅读,以逐渐习惯Git的约定和风格。

这里要考虑的主要是相对的命名方式。假设你知道你想要从上一个提交中获取某个文件的版本。你可以运行git log获取其哈希ID,然后使用git checkoutgit restore来获取它。但是由于HEAD始终命名当前提交,如果我们只是告诉Git:“从HEAD向后退一次提交”,我们就会得到正确(前一个)的提交。我们可以使用帽子^或波浪线~后缀来实现:

git checkout HEAD^ -- fileA

或者使用波浪号。 (使用任何您喜欢的; 如果您特定的命令行解释器对插入符或波浪号字符感到困扰,请考虑使用另一个字符。)或者我们可以回退两个提交:在这里,我们需要使用波浪号后缀,因为插入符后缀在数字上有不同的含义。
git checkout HEAD~2 -- fileA

使用我们的示例:

...--G--H   <-- main
         \
          I--J   <-- feature (HEAD)

这意味着当前的“分支”是“feature”,当前的“提交”是“J”,“HEAD~2”或“feature~2”意味着“提交H”。我们也可以直接使用名称“main”。
git checkout main -- fileA

如果我们想要提交G,我们可以将其命名为feature~3(向后数三步:J-1=I,-1=H,-1=G),或者main~1,或者main^,或者main~。这两个符号——帽子和波浪线——可以帮助您走得更远。之后,还有一些搜索提交的方法,但有时会变得非常棘手(一些搜索从所有分支开始!)。

1
让我们将您的3个状态称为以下名称(按时间顺序显示最近的状态):
commit C: your changes to fileB
commit B: your changes to fileA
commit A: where you started before you made any changes

现在你的分支看起来像是A-B-C,但是听起来你希望得到A-C',其中C'只包含对fileB的更改,但有一个新的哈希(因为当您更改提交中的任何内容时,包括父提交时,必须具有新的哈希)。 通过交互式变基,一种简单的方法是从本地分支历史记录中删除提交B:

git rebase -i A # where A is the commit ID (hashcode) of A

或者,同样地:
git rebase -i HEAD~2 # the second parent of your current head is A in this case

当出现交互式变基提交时,请注意它们按时间顺序呈现(与git log相反的顺序)。这是变基将发生的顺序。您想删除B(或完全删除该行),并保留C作为pick。保存并关闭,B将消失,C将被重写为C'。
附注1:如果您已经将分支推送到远程,并且正在使用的分支是共享的或已合并到另一个共享分支中,则可能不希望重写分支的历史记录。在这种情况下,最好创建一个新的提交D,以撤消对B的更改。您可以使用“git revert B”来执行此操作,它将产生“A-B-C-D”,其中D是B的相反数。

附注2:如果您不想重写历史记录,并且您不能通过还原提交来简单地恢复文件,那么您可以使用torek's answer在新提交中将文件恢复到先前的版本。该答案还有一个额外的好处,即如果您希望这样做,您可以恢复文件的特定版本,而不管自那时以来该文件发生了什么。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接