Git checkout 无意中删除了未被跟踪的文件

3
我遇到了Git的一个奇怪行为: 我有一个包含一些未跟踪文件和文件夹的存储库,这些文件和文件夹在.gitignore文件中指定。
我所做的确切步骤如下:
1. 存储了4个文件:git stash 2. 检出了几个月前的第一个提交:git checkout <第一个提交的哈希值> 3. 查看周围情况而没有改变任何东西 4. 回到我的工作分支,执行:git checkout <我的工作分支> 5. 应用存储:git stash apply
然后我注意到一些(但不是全部)未跟踪的文件和文件夹消失了。这是怎么回事?
附加信息:
- 存储的文件与消失的文件无关,我只是为了完整性而记录了存储操作。 - 我没有执行命令git stash --include-untracked或git stash save -u,如@Ashish Mathew所猜测的那样。 - 似乎只有那些在第一个提交时不在.gitignore中但后来被添加到其中的文件和文件夹消失了。

https://dev59.com/1HRA5IYBdhLWcg3wxA1N#835561 - Ashish Mathew
1个回答

11
存储的文件与消失的文件无关......确实。
似乎消失的只有那些在第一次提交时不在.gitignore中但之后被添加到其中的文件和文件夹。这加上另一件事,(几乎肯定)是问题的根源。幸运的是,你应该能够找回那些文件——或者至少是它们的某个版本。不幸的是,你需要把它们的名称一个个列出来并大费周折地使用Git,并且可能会得到错误的版本。见底部的示例会话。
首先,请注意,只有未跟踪的文件会被忽略。
即使.gitignore文件指示要忽略已跟踪的文件,也不会被忽略。只有未跟踪的文件会被忽略:文件可以是已跟踪、未跟踪但未被忽略,或未跟踪且被忽略。
但是等等,什么是未跟踪的文件呢?
未跟踪的文件是不在索引中的文件。
这个定义是Git中为数不多的简单明了的定义之一。或者说,如果能清楚索引是什么,那么它将非常清晰。不幸的是,索引非常难以看到。
我对索引的最好描述是这个:索引是你构建下一个提交的地方。
这个索引,也称为缓存区和暂存区,跟踪(即索引)你的工作目录。你的工作目录是你进行工作的地方:它有你的文件以正常的非Git格式存在。永久存储和只读存储在提交中,在Git存储库内部的文件具有特殊的、压缩的、仅限于Git的格式。索引“坐在”这两个位置之间:它拥有你所有可提交的文件,来自你的工作目录,全部准备好被提交了。但是索引中的文件是可变的(与提交内部的不同),即使它们已转换为特殊的Git格式。
这意味着你的索引很少是空的。大多数时候,它只是匹配你当前的提交。那是因为你刚刚检出了那个提交,将那些文件放入你的索引(以Git-only形式,准备下一次提交)和你的工作目录(以普通的文件形式,准备使用或编辑)。如果您修改了文件F并运行git add F,则git add将替换在索引中之前(以Git格式)存在的文件副本。索引不是空的 - 它包含F和其他所有内容 - 它只是匹配了当前提交,因此大多数Git命令在您在工作树中更改F之前不提到F
所以,让我们考虑:
检出我几个月前的第一个提交:git checkout <hash of first commit> 这告诉Git:从那个非常初期的提交中填充索引和工作树。假设我们尚未实际运行此命令,只需考虑:这会做什么?那个提交中有什么?
嗯,当您进行提交时,提交中有您添加到索引中的任何内容。这包括您稍后决定必须将其取消跟踪的文件abc.txt
要取消跟踪,您必须在某个时候从索引中删除abc.txt,可能使用以下命令:
git rm --cached abc.txt

(在保留工作目录副本的同时,删除索引副本)。在运行 git rm --cached 之后,您执行了 git commit 。从运行 git rm --cached 到现在,该文件在索引中存在。它在工作树中。因此,它是未跟踪的。

检出任何提交都会从该提交填充索引

现在,您告诉Git要检出您的第一个提交了...那么,第一次提交包含 abc.txt 。Git需要将 abc.txt 的提交版本复制到索引中并且 复制到工作树中。

此时,如果工作树中已经有一个 abc.txt ,Git将检查您是否要用不同的 abc.txt 覆盖它。大多数情况下,Git将拒绝这样做,并告诉您先将其移开。 但是,如果工作树中的 abc.txt 与提交中的匹配,则可以安全地使用来自提交的 abc.txt 填充索引。毕竟,它与工作树中的文件匹配。

因此,在此时,Git将所有文件从该提交中提取到索引和工作树中。 (有一些复杂但试图安全的例外情况:请参见当当前分支存在未提交的更改时检出另一个分支)。并且,哇喂,现在 abc.txt 在索引中。 现在它被跟踪了!

因此,现在您查看旧提交,并决定:

git checkout <my working branch>

现在Git必须从第一个提交中切换索引和工作树内容,该提交包含 abc.txt ,到<my working branch>的尖端提交中。该提交中没有 abc.txt 。 Git将从索引中删除文件...并从工作树中删除它,因为它是已跟踪的

一旦检出完成,现在该文件不在索引中。好吧,它也不在工作树中。()。如果您将其放回到工作树中,现在它就是未跟踪的。但是,您可以从哪里获取它呢?

答案就在我们眼前:它就在那个第一次提交里。当你运行 git checkout <hash> 时,Git 把文件复制到了索引和工作目录中(除了它最终并没有必要再次触及工作目录中的版本)。当你运行 git checkout <my working branch> 回到工作分支时,Git 会将文件删除,但提交是只读的,而且(大多数情况下)是永久的,所以文件仍然以 Git-Only 的形式存在于提交 <hash> 中。
诀窍是把它从提交 <hash> 中拿出来,而不是将它放回到索引中,以便以正常的、非 Git 格式留存。如今实现这一点的简单方法是使用 git show hash:path > path,例如:
git show hash:abc.txt > abc.txt

(请注意,默认情况下 git show 不会应用行尾翻译和smudge过滤器,但在现代Git中,您应该能够使用 --textconv 实现这一点)。

您将不得不针对 Git 删除的每个文件执行此操作,这可能相当麻烦。


示例会话:.gitgnore 使Git能够覆盖数据

我为测试目的创建了一个微小的存储库。 在此存储库中,我进行了初始提交,其中包含一个 README 文件和一个包含一行文本“original”的 abc.txt 文件:

$ mkdir tt
$ cd tt
$ git init
Initialized empty Git repository in ...
$ echo original > abc.txt
$ echo for testing overwrite > README
$ git add README abc.txt
$ git commit -m initial
[master (root-commit) a721a23] initial
 2 files changed, 2 insertions(+)
 create mode 100644 README
 create mode 100644 abc.txt
$ git tag initial
$ git rm abc.txt
rm 'abc.txt'
$ git commit -m 'remove abc'
[master 20ba026] remove abc
 1 file changed, 1 deletion(-)
 delete mode 100644 abc.txt
$ touch unrelated.txt
$ echo abc.txt > .gitignore
$ git add .gitignore unrelated.txt 
$ git commit -m 'add unrelated file and ignore rule'
[master 067ea61] add unrelated file and ignore rule
 2 files changed, 1 insertion(+)
 create mode 100644 .gitignore
 create mode 100644 unrelated.txt

现在我们有了一个包含三个提交的代码库:

$ git log --oneline --decorate
067ea61 add unrelated file and ignore rule
20ba026 remove abc
a721a23 (tag: initial) initial

让我们把一些珍贵的数据放入(被忽略的)abc.txt文件中:

$ echo precious > abc.txt
$ git status
On branch master
nothing to commit, working tree clean
$ cat abc.txt   
precious

现在让我们来看一下提交记录 initial

$ git checkout initial
Note: checking out 'initial'.

You are in 'detached HEAD' state. [mass snip]

HEAD is now at a721a23... initial
$ cat abc.txt
original

糟糕,我们珍贵的数据已被损坏!

.gitignore指令给了Git覆盖文件的权限。为了证明这一点,让我们将abc.txt取消忽略(但也不跟踪它):

$ cp /dev/null .gitignore
$ git add .gitignore
$ git commit -m 'do not ignore precious abc.txt'
[master 564c4fd] do not ignore precious abc.txt
 Date: Thu Feb 8 14:16:08 2018 -0800
 1 file changed, 1 deletion(-)
$ git log --oneline --decorate
564c4fd (HEAD -> master) do not ignore precious abc.txt
067ea61 add unrelated file and ignore rule
20ba026 remove abc
a721a23 (tag: initial) initial
$ echo precious > abc.txt
$ git status
On branch master
Untracked files:
  (use "git add <file>..." to include in what will be committed)

    abc.txt

nothing added to commit but untracked files present (use "git add" to track)
现在如果我们要求切换到 initial
$ git checkout initial
error: The following untracked working tree files would be overwritten by checkout:
    abc.txt
Please move or remove them before you switch branches.
Aborting

忽略文件存在一个令人烦恼的副作用:它们可以被覆盖。我(以及过去的其他人)曾尝试教Git区分“被忽略但可覆盖”和“被忽略但不可覆盖”,但是无法简单修复,因此放弃了努力。

(我曾经认为Git在这方面变得更好了,但这个例子表明,至少在Git 2.14.1中仍然存在问题,而这正是我在这组测试中使用的版本。)


如果一个文件最终进入了代码库,使用 SVN 也会出现同样的情况。这种情况比较明显,但是也没有真正的解决方法。我有一个存放重要文件的地方,无论什么原因都不能被跟踪——就在代码库之外。 - zzxyz
非常好的答案,非常感谢您分享您的知识。我必须逐步进行。 - Benni
@torek:根据您的说明,我能够使用测试环境再现观察到的行为。正如您建议的那样,我也能够通过git show hash:abc.txt > abc.txt --textconv恢复已删除的文件。但是,您只能还原文件在那个特定提交时的状态。在将文件添加到.gitignore并使用git rm --cached之后进行的更改将永久丢失,只要您在执行该步骤之前检出提交之前的版本。我想知道是否有一种方法可以在稍后的时间点将文件添加到.gitignore并仍然能够安全地检出旧的提交。 - Benni
通常情况下,git checkout 应该拒绝覆盖与提交版本不匹配的 abc.txt 版本。我记得在某个时候(Git 1.6?),将文件列在 .gitignore 中会使 Git 愿意覆盖它,但我使用 Git 2.x 进行了测试,它说“would clobber”(中止检出操作)。你使用的是哪个版本的 Git? - torek
@torek:我可以确认这种行为在Linux上的1.8.3.1和2.6.1.windows.1都存在。两者都使用默认配置设置。 - Benni
嗯,这是不好的行为,但是有用的信息。我刚在2.14.1中再次测试了一下,确实情况是这样的,将文件列在.gitignore中使Git可以自由地覆盖该文件。将.gitignore更改为忽略它后,git checkout initial(其中initial是指向具有旧abc.txt的提交的标签)会显示:error: The following untracked working tree files would be overwritten ... - torek

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