VonC的回答和Romain Valeri对该回答的补充都是正确的,但可能在不同方面难以可视化或理解。以下是一种相对直观的理解方式。
在Git中处理提交时,每个文件最多有三个副本。一旦您知道提交包含每个文件的完整快照,以只读(且仅限于Git、压缩和去重)格式,您就会明白为什么需要两个副本:
Git's read-only copy in HEAD your working tree copy
---------------------------- ----------------------
file1.ext file1.ext
file2.ext file2.ext
readme.md readme.md
即使 "提交" 中的
file1.ext
内容与您的工作树中的
file1.ext
内容匹配,您的工作树是您可以查看、处理/使用 Git 从提交中提取的文件的地方,但 Git 复制是以某种特殊、奇怪、压缩和只读格式进行的。 只有 Git 自身才能读取此文件,甚至 Git 自身也不能覆盖它。
1 您的工作树包含普通的日常文件,每个人都可以按照普通方式阅读和编写,因此每次检出提交时,Git 确实需要复制它。
同样的原则也适用于其他版本控制系统:在 Mercurial、SVN、CVS、ClearCase 或其他任何 VCS 中,您通常会发现存在多个文件副本(具体细节大为不同取决于 VCS)。不过 Git 的特别之处在于,它不仅提供了两个文件副本,而是提供了三个副本:
HEAD (r/o) staging area working tree
---------- ------------ ------------
file1.ext file1.ext file1.ext
file2.ext file2.ext file2.ext
readme.md readme.md readme.md
HEAD
复本已经
提交且无法更改,这就是它的状态。工作目录里的复本则可以按照需要修改。奇怪的是,在
HEAD
和工作目录版本之间还有一个额外的复本存在。
这个额外的版本不容易被发现,至少不容易直接看到。
2 但它确实存在。为什么呢?嗯,一个答案是:“没有原因”——毕竟,那些
其它的版本控制系统并不这样做。
3 但是Git却这么做,并且Git会有一些除了“与众不同”之外的理由。特别地,这个“暂存区”的存在允许你使用
git add -p
来
部分地添加文件。还有一整套类似的操作(如
git reset -p
,
git checkout -p
),尽管我个人对这些操作不太感冒,但它们确实存在,并且常常作为暂存区存在的理由。
在暂存区中存储的数据
实际上和Git在提交中所使用的只读、压缩和去重复
形式是相同的。这对Git本身来说意味着
git commit
的速度可以
非常快(至少相比于所有其它的版本控制系统而言)。当你运行
git commit
时,暂存区中的文件复本即已经
准备好被提交。几乎不需要额外的工作。
5 当你运行
git checkout
时,Git会用所选提交版本的所有文件来预填充索引/暂存区。当你运行
git add
时,Git会进行以下操作:
- 压缩并散列工作目录文件的内容;
- 检查是否有重复的文件;并且
- 如果存在重复的文件,则直接复用旧的文件,否则保存新的文件。
因此,该文件已经准备就绪,Git可以仅更新其索引条目/暂存复本中的新或重用的内部散列ID。6 这意味着索引/暂存区现在已准备好被提交。
将这些放在一起,我们可以看到以下内容:
full-commit-checkout (or git switch
): fills in the index / staging-area and your working tree:
HEAD staging working tree
---- ---------- -------------
file1.ext -> file1.ext -> file1.ext
file2.ext -> file2.ext -> file2.ext
readme.md -> readme.md -> readme.md
git add
: copies from working tree to index / staging-area. Let's say we just git add file2.ext
:
HEAD staging working tree
---- ---------- -------------
file1.ext file1.ext file1.ext
file2.ext file2.ext <- file2.ext
readme.md readme.md readme.md
现在其他各种操作也开始有意义了:
git rm --cached
: removes the index copy, leaving everything else untouched. Let's say we git rm --cached file2.ext
:
HEAD staging working tree
---- ---------- -------------
file1.ext file1.ext file1.ext
file2.ext file2.ext
readme.md readme.md readme.md
git reset
, in one of its modes: restores the staged copy (only) from the HEAD copy, with git reset -- file2.ext
for instance:
HEAD staging working tree
---- ---------- -------------
file1.ext file1.ext file1.ext
file2.ext -> file2.ext file2.ext
readme.md readme.md readme.md
git rm
without --cached
: removes both index and working tree copies:
HEAD staging working tree
---- ---------- -------------
file1.ext file1.ext file1.ext
file2.ext
readme.md readme.md readme.md
git restore
: restores staging, working-tree, or both, depending on flags; let's say we now run git restore -SW file2.ext
to restore both:
HEAD staging working tree
---- ---------- -------------
file1.ext file1.ext file1.ext
file2.ext -> file2.ext -> file2.ext
readme.md readme.md readme.md
git checkout
命令包含两种操作模式,可以模拟
git restore
的功能:它可以将文件从暂存区复制到工作区,或者将文件从
HEAD
复制到暂存区和工作区。
7 由于这种操作即使你没有将其保存在任何地方也会覆盖工作区文件,因此使用
git switch
而不是
git checkout
更加 "安全",因为你不会意外进入这种破坏性的模式。
8
因此,简而言之,你的第二个
git add
命令覆盖了第一个
git add
写入的暂存区副本,并抛弃了前一个暂存区副本。现在要恢复非常困难。
1 严格来说,只要文件以 Git 称之为 "loose object" 的形式存储,读取文件并不难:使用任何 zlib 解压程序打开底层对象并对其进行解压缩,然后丢弃 Git 添加的头信息。但就是找到这个对象很麻烦,而且它可能与 "loose" 相反,也就是 "packed",那时你就真的遇到麻烦了。
虽然覆盖文件在物理上是可能的,但由于对象的名称是对象数据的加密校验和,因此简单地覆盖文件会损坏数据,达到 Git "这个对象损坏" 的程度,从而拒绝提取它。你会知道仓库已经受损,需要找到其他未受损的克隆。
2 要想看到它不容易,请运行 git ls-files --stage
命令;请注意,在一个大的仓库中,这会产生许多输出。
3 比如 Mercurial 就没有,但是有一个名为 "dirstate" 的隐藏东西可以完成 Git 的暂存区的一部分功能。用户面向的 Mercurial 的 dirstate 和 Git 的暂存区之间的区别在于,你甚至不必知道 dirstate 的存在。Git 时不时地把其暂存区/工作区推到你的面前:看!我有这个额外的副本!它不酷吗?看,你看见了吗! 你确实需要意识到它。
4 使用 git ls-files --stage
命令可以揭示这个 "秘密",所以它并不是真正的秘密。但除非你开始使用 git ls-files --stage
本身或结合使用 git update-index
,否则你不需要知道它。
5额外的一点工作是Git需要运行内部等效于git write-tree
。这保存了文件的名称和模式。正如Romain Valeri所指出的,数据——文件的内容已经被"预先保存"了。
6读者练习:如果你使用 git add
添加了一些内容,然后再通过新内容覆盖它而没有提交它,会发生什么?这里有一个 Git 对象似乎永远不会被使用。请查看 git gc
文档 ,了解最终会发生什么。
7 git restore
命令具有从 HEAD
到 工作树 的复制功能,如果您愿意,则可以跳过暂存区;git checkout
无法执行此操作。我不知道您是否要这样做,但如果您决定这样做,请记住,git restore
比另一种 git checkout
模式更加强大。
8在 Git 2.23 及以后的版本中,“意外的破坏性模式”不再发生,现在会提示您的 git checkout zorg
请求是有歧义的。以下是旧版 Git 中发生此情况的地方:
- 假设您有一个名为
origin/zorg
的 分支。
- 假设您还有一个名为
zorg
的 文件,并且您运行了 git checkout develop
命令并得到了该文件的 develop
分支末端副本。
- 现在假设您花了最后一个小时来计划解雇出租车司机的邪恶计划。
- 现在您休息一下喝杯咖啡(小心别被樱桃卡住了)。当您回来时,您想:等一下,我想在
zorg
分支上。因此,您运行了 git checkout zorg
命令。
你没有一个名为
zorg
的分支,但你有一个叫做
origin/zorg
的分支,你期望 Git 从
origin/zorg
创建并切换到一个新的分支
zorg
,如果这是安全的话,或者给出一个错误提醒你先储存或提交你的文件。但是相反,Git说:
哦,你想让我清除你在文件zorg
上最近一小时的工作,因此提取文件
zorg
的暂存副本到你的工作目录。
如果你使用了
git switch zorg
,Git 就会知道你想创建一个新的分支,并尝试安全地执行。但是相反,Git 摧毁了你的工作。糟糕!别杀很多人(甚至是 Mangalores)来发泄你的挫败感,好吗?
git switch
,它不会错误地分离HEAD,而git checkout
则可能会:https://dev59.com/CG865IYBdhLWcg3wIrFg#3965714 - VonC