为什么 git 在提取未跟踪两个文件的提交时删除一个被忽略的文件(但不是一个未被忽略的文件)?

Why does git remove an ignored file (but not an un-ignored file) when pulling a commit that untracked both files?

考虑以下情况:

  1. 远程存储库创建并跟踪两个文件
  2. 稍后远程存储库将两个文件之一添加到 .gitignore(我知道这是不正确的,但它发生在我们的组织中)
  3. 本地存储库克隆远程存储库
  4. 在本地存储库中,我们使用 git rm --cached
  5. 取消跟踪这两个文件
  6. 远程提取这些更改

我希望遥控器仍然有这两个文件,并从现在开始停止跟踪它们。

为什么会这样?

这是一个 bash 脚本 MWE,它复制了我的意思

#! /bin/bash

# 1. Set up remote repository
mkdir remote

cd remote
git init .
touch file_to_remain.txt
touch file_to_remove.txt
touch file_to_ignore_and_remove.txt
git add .
git commit -m 'first commit'
echo "file_to_ignore_and_remove.txt" > .gitignore
git add .
git commit -m 'gitignore ignores a file that is already in the index'

# 2. clone local repo
cd ../
git clone ./remote local

# 3. untrack both files
cd local
git rm --cached file_to_ignore_and_remove.txt
git rm --cached file_to_remove.txt
git add .
git commit -m 'removed two files from index'

# 4. pull changes into remote
cd ../remote
git remote add origin `pwd`/../local
git pull origin master

相反,发生的是:

另一个发现:如果我在第 3 阶段(在 MWE 中)提交之前执行 git statusfile_to_remove.txt 显示为已删除和未跟踪,而 file_to_ignore_and_remove.txt 仅显示为已删除。当我执行 git add . 时,只记录 file_to_ignore_and_remove.txt 的删除。

您的问题从第 1 步开始,假设为:

  1. a remote repository creates and tracks two files

A 存储库 不跟踪文件(也不跟踪 not-track 文件)。 Git 存储库主要由一组 提交 组成。每个提交都包含 制作的 提交告诉 Git 包含 in 的所有文件的完整快照。

这意味着什么——在我们讨论跟踪与未跟踪的问题之前——我们可以有一个包含文件 f1f2 的提交 a123456,另一个提交b56789a 包含文件 f2f3secret,第三次提交 cbcdef0 包含文件 f3f1 .

成功签出提交 a123456 后,您会发现您有名为 f1f2 的文件,其中包含提交 [=11= 中的快照中的任何内容].成功检出提交 cbcdef0 后,您会发现您有文件 f1f3,其中包含提交 cbcdef0 中快照中的任何内容。提交 b56789a 中的内容并不重要,因为我们从未检查过它,即使 存储库 有该提交。我们从未注意到名为 secret 的文件,因为我们从未查看包含该文件的 commit

Git 在 commit-by-commit 基础上工作。我们使用 git checkoutgit switch.1 选择一些要处理或处理的提交,因为 any 的所有部分都存在提交完全是 read-only,每个提交中的文件都以特殊的 Git-only、压缩和 de-duplicated 格式存储,other 程序在您的计算机无法使用,此“选择一些要处理的提交”步骤通过 将文件从提交 复制到工作区。提交中的文件不可见!它们对 Git 本身是私有的。您使用的文件 可见的,并且是普通文件,但它们只是从 Git 实际使用的文件中 复制而来(通过 Git 对象 存储,保存在 Git 内的数据库中。

这意味着当您使用 Git 时,您看到和使用的文件实际上并不 Git. 记住这一点对于接下来的内容很重要。同时,请记住 Git 的基本“存储单元”,可以说是提交。根据定义,每个提交包含每个 文件,Git 知道该提交的文件。但是这些文件在Git里,你问了就直接复制出来了。


1new-in-Git-2.23 git switch 实现了 git checkout 的“安全”子集,其中 new-in-Git- 2.23 git restore 实施“不安全”子集。如果您一直使用 Git 2.23 或更高版本,并想避免某些悲惨事故,最好训练自己使用这两个新命令。不过,旧的 git checkout 命令继续有效,所以如果您已经 self-trained 只使用 git checkout,您可以继续这样做。


关于提交的更多信息

除了我们已经在上面描述或假设的事实之外——提交是由那些丑陋的大 random-looking 哈希 ID 编号,并且每个提交有每个提交 的文件的完整(但 read-only)快照,关于提交还有一件事需要了解:每个文件都包含一些元数据,其中,与提交的所有部分一样,完全 read-only:创建提交后无法更改它。

提交中的元数据包括提交人的姓名和电子邮件地址等内容。它包括 date-and-time-stamps(两个,出于各种原因)。它包括一条日志消息,其中提交提交的人应该解释 为什么 他们进行提交,尽管此解释的质量取决于提交作者。不过,对于 Git 本身而言,最重要的是,任何一次提交的元数据都将包含一个 较早提交 哈希 ID 的列表。这些是相关提交的 parents

大多数提交只有一个父项。这些是普通的 (non-merge) 提交。它们 Git 存储库中的历史。添加一个新的提交,或许多新的提交,到一些 Git 存储库,同时保留现有的,是我们添加历史记录的方式。我们可以通过自己提交一次来一次提交一个提交,或者通过从其他 Git 存储库中获取许多提交来集体提交。使所有这些工作成功的秘诀与那些又大又丑的哈希 ID 有关。我们不会在这里适当地介绍它;我们只是说 every Git 使用 same 加密哈希函数来计算哈希 ID,因此所有 Gits 同意任何特定的提交都会获得其特定的哈希 ID。

无论如何,Git 安排每个 new 提交记住它的 前一个提交 的哈希 ID。这意味着普通 (non-merge) 提交形成一个简单的 backwards-looking 提交链:

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

在这里,我们用一个大写字母替换了每个实际的哈希 ID,并在右侧绘制了最新的提交。我们将其哈希 ID 命名为 H。 Git 可以通过哈希 ID 找到此提交,因为它存储在所有 Git 对象的数据库中,由哈希 ID 索引。这让 Git 可以获取存储的快照,也可以获取元数据。

不过请注意,提交 H 的元数据包括早期提交 G 的哈希 ID。所以 Git 可以找到提交 G,这让 Git 获得 G 的存储快照。通过比较存储的快照——G vs H——Git可以告诉我们改变了两次提交。这就是事情变得有趣的地方。

当然,由于 G 是一个普通提交,只有一个父提交,G 的元数据中存储了 F 的哈希 ID。这意味着 Git 也可以从 G 返回到 F,并比较两个快照。同时 F 存储在 its 元数据中,its 父哈希 ID,因此 Git 可以根据需要退回另一步。这一直重复到第一次提交——历史的开始——第一次提交根本没有没有父级,告诉Git:这是开始。例如,这就是 git log 将停止的地方,因为 运行 没有提交。

最终,这也是 分支名称 的工作方式。这里就不细说了,每个branch name只是持有one个hash ID。该哈希 ID 是我们希望在某些提交链中调用 last 提交的提交的 ID。即使在此之后有更多提交,那个特定标记的提交也是 this 链的结尾。正因为如此,Git 最终总是向后推:它从结尾开始,然后通过历史倒推到开头。

请记住,none 提交中的这些东西是可以更改的。 分支名称可以更改:例如,您可以根据需要重命名分支,但更重要的是,您可以将不同的哈希ID填充到一个分支名称。当您这样做时,作为链末端的提交已经改变。这就是分支增长或(如果需要)收缩的方式:通过将分支名称移动到我们添加的新提交,或者通过将分支名称向后移动到某个历史提交。

Git 的 index 和你的工作树

现在我们来讨论实际处理提交的问题。正如我们之前提到的,提交中的所有内容——所有文件的快照和元数据——都是 read-only。没有什么可以改变它,甚至 Git(因为使 Git 作为分布式系统工作的散列技巧)。但是要在提交中使用 in 文件,我们肯定需要能够读取它们——当它们在 object-ized 和 [=582] 中时我们不能=] 内部 Git 格式——我们几乎肯定也需要能够写入它们。

这就是为什么 Git 将提交的文件 复制到 之外。副本进入工作区域,Git 称为您的 工作树 work-tree。正如我们之前看到的,这些是您可以查看和使用的文件。它们实际上只是普通的日常文件。 Git不控制这个工作区! Git 确实——通常——最初是在 git clone 上实现的,但是 最初是在 git init 上实现的。这个工作区现在是你的,你可以随意处理。请记住,git checkout 是对 Git 的请求,用从某个提交中提取的文件填充您的工作区。

请注意,这意味着您的各种文件必须至少有 两个 个活动副本:

  • current commit中有一个被冻结的Git取出并放入你的work-tree;和
  • 您的 work-tree 中有一个您正在处理/正在处理的内容。

Git 可以 到此为止,工作区充满了文件,并且有提交。其他一些版本控制系统就是这样做的。但这不是 Git 所做的。相反,Git 在每个文件的冻结提交副本和您的 work-tree 副本之间挤压一个 third 副本。这意味着如果您在提交 a123456 中有这些文件 f1f2 并且这是您当前的提交,那么 Git 将具有:

  • 每个文件 f1f2 在其提交中的冻结副本;
  • f1f2 的另一个“副本”准备进入 下一个 提交;和
  • f1f2 在您的 work-tree 中可用的副本。

这中间&qot;copy"—在这里用引号引起来,因为它是 Git 的内部格式,即 de-duplicates 文件,所以它最初只是从字面上共享原件commit——每个文件都位于 Git 给出三个名称的区域。Git 称其为 index,或 staging area,或者——现在很少见——缓存。姓氏主要出现在标志中,比如git rm --cached

每个文件的索引副本的特别之处在于Git会让你替换它。无法替换提交中的副本,因为无法更改任何现有提交。但是索引只是一个 提议的 提交。它实际上还不是一个提交。那么 Git 的索引 中的内容可以 更改。

这就是 git add 所做的。这也是 git rm --cached 所做的:它更改了 提议的下一次提交 。更改提议的提交不会影响任何现有的提交,所以没关系。 Git 通过执行以下三项操作之一实现此更改:

  • 替换一些现有文件:用新版本覆盖索引f12
  • 添加一个 new-to-the-proposed 提交文件:为我们在提议的提交中没有的文件创建一个新的索引条目;或
  • 从建议的下一次提交中删除一个文件。

然后,所有这些更改都发生在 Git 的 index 中。这意味着建议的下一次提交始终是最新的,当您 运行 git addmake 该提交时,Git 只需快照Git 索引中的任何内容。

这导致了跟踪文件的定义。从技术上讲,Git 只是定义了术语 未跟踪的文件 ,但很明显如何反转它。


2由于旧文件版本内容是共享的,这实际上并没有覆盖它,而只是创建一个新的,或找到 de-duplicate 的其他一些现有副本。这背后的实际机制使用了 Git 用于提交的相同对象哈希 ID 技巧。提交总是得到一个 unique 哈希 ID,因为 about 每个提交总是保证是唯一的。 (这背后有很多魔法,这里实际上不需要日期,但是 Git 保证的一种方式是每个提交都“现在”作为 date-and-time 戳记。你有为了使这部分相同,每秒进行多次提交。)文件内容,如果它们复制了一些较早保存的版本,最终将使用旧版本的哈希 ID,因此自动 de-duplicated.


跟踪与未跟踪文件

一旦您知道 Git 如何使用其索引,作为提议的下一次提交,跟踪或未跟踪文件的定义变得几乎微不足道:跟踪文件是 Git的索引.

仅此而已,真的。 untracked 文件是您现在在工作树中的文件,它现在不在 Git 的索引中。将该文件 放入 Git 的索引中(例如 git add),它就变成了 tracked 文件。将它从 Git 的索引中删除——例如,使用 git rm --cached——它变成了一个 untracked 文件。您可以随时 运行 git addgit rm --cached,因此您可以随时将文件从跟踪转换为未跟踪,反之亦然。

但是这里有一个大的、毛茸茸的皱纹。当您 运行 git checkout 选择对 使用 的提交时,Git 将:

  1. 从提交中填写Git的索引;然后
  2. 从 Git 的索引中填写您的 work-tree。

假设您正在提交 a123456,其中包含文件 f1f2。您以正常的日常方式到达那里,现在您的工作树中有文件 f1f2。 Git 的索引中有 f1f2这两个文件的所有三个副本都匹配 ,因此从 a123456 移动到 cbcdef0 是非常安全的。因此,您 运行 git checkout 在标识提交 cbcdef0 的分支名称上,例如,将 切换到 该提交。

提交 cbcdef0 说我们应该有名为 f1f3 的文件。 Git 的 index 当前有 f1f2 在里面。要使 Git 的索引保留 f1f3,Git 必须从索引中 删除 f2。因为 f2 是一个 tracked 文件——它在索引中——Git 也会 从中删除 f2你的工作树。 Git 可以将 f1f3 的正确副本放入其索引和您的工作树中,结帐完成并且文件 f2 消失了.

但它并没有真的消失,是吗?在提交 a123456 中,它是绝对安全的。只是 git checkout 那个提交和文件 f3 会消失——它现在存在并且被跟踪但不应该存在t 因为 a123456 缺少 文件 f3—文件 f2 会回来,从 a123456 中提取,现在在两个 [=613] =]的索引和你的工作树。

请注意,如果您愿意,现在可以 运行 git rm --cached f3,然后再切换到 a123456 从 Git 的索引中删除了 f3。现在 f3 是一个 未跟踪的文件 。现在你可以 git checkout 提交 a123456,并且 Git 不会 删除 f3,因为 f3 不在Git的索引。事实上,您的工作树中有一个文件 f3:嗯,那是 您的 业务。它不在 Git 的索引中,所以它是一个未跟踪的文件:您的一个文件,无论出于何种目的您都留在那里:它不是 Git 需要打扰的。

您的本地和“远程”存储库

但是现在,在您的示例中,您已经将另一个存储库和 work-tree 组合加入其中。您现在拥有 local 存储库,您在其中 运行ning git rm --cachedgit add 并进行新提交。没关系!但是您在某个地方(在您自己的机器上或在其他机器上)也有另一个 Git 存储库。 Git 存储库有 自己的 索引和 自己的 工作树。

如果您正在进行一些提交,并使用 git rm --cached 删除了一些文件,它现在已从 Git 的索引中消失,但仍在 您的 中工作树。您进行了新的提交, 缺少 文件,一切仍然正常。

但是现在,在另一个 Git 存储库中,您做一些事情来获得这个新的提交。你仍然检查了旧的提交,并且有一些文件在 Git 的索引和你的工作树中,在这台机器上的另一个存储库中。现在你告诉Git:切换到其他提交,而另一个提交缺少文件。该文件在Git的索引这里——这个索引是这个存储库的一部分——所以文件是tracked 在这里,所以 Git 删除文件 ,就像它应该的那样。

如果您使用这种 git rm --cached 技巧从 Git 的索引中删除文件,您将在其他已签出提交的 Git 存储库中始终遇到此问题并在将文件保留在 your 工作树中的同时进行新的提交。那是因为 他们的 索引和 他们的 Git 从未被告知保留文件。他们看到的只是“旧提交有文件,新提交缺少文件”:这是一条 删除文件 的指令。它没有从 存储库 中消失,但它从 工作树 .

中消失了

关于.gitignore

.gitignore 文件命名错误。 Git 将从 Git 的索引中的任何内容构建一个新的提交。那是提议的下一次提交。如果文件在 in Git 的索引中,则它在 Git 的索引中,无论名称或模式是否在 .gitignore 文件。因此:

  1. the remote repository later on adds one of the two files to .gitignore (that's not correct, I know, but it happened in our organization)

不会*立即有害。以后可以;这不完全是错误,但我认为我自己是一个不好的做法。

.gitignore 文件有几个功能。最重要的是影响人们从事新工作的那些。

当你运行git status时,你的Git:

  1. 打印一些通常有用的东西,比如 on branch xyzzy;
  2. 可能会或可能不会打印有关 为提交准备的文件的内容;
  3. 可能会或可能不会打印有关 未暂存提交的文件;
  4. 可能会也可能不会打印有关未跟踪文件的内容

步骤 2 中打印的文件名列表是比较当前提交提议的下一次提交 的结果。对于每个相同的文件,Git 什么也没说。对于每个以任何方式不同的文件,Git 表示该文件是 staged for commit,并带有一个 status-code 字母:M 表示已修改(文件同时存在于 HEAD commit and index / staging-area), D for deleted (file exists in HEAD but not in index); A表示已添加,依此类推。3

第 3 步中打印的名称列表是将提议的下一次提交 与您的工作树进行比较的结果。也就是说,Git 区分索引中的文件与 work-tree 中的文件。对于那些相同的,Git 什么也没说。对于那些不同的,Git 打印与以前大致相同,但现在称这些为 not staged for commit。您可以 运行 git add 将文件的 work-tree 版本复制到索引中。

这里有点特别的是,Git 不是说某些文件是 Added,而是收集所有这些文件名,然后将它们洗牌到第 4 步。这些是你的 未跟踪的文件。 Git 现在抱怨他们,暗示你应该使用 git add 来做广告他们。

在许多设置中,会有许多未跟踪的文件,绝对不应该 git added,因为它们不应该在下一次提交中。添加它们会将它们放入提议的 next 提交中,这意味着现在您必须将它们取出 (git rm --cached),这样当您做它。我们想让 git status 就此 闭嘴

因此,我们在 .gitignore 中列出这些文件。这意味着也许这个文件应该命名为 .git-do-not-whine-about-these-untracked-files-in-git-status-output.

而不是 .gitignore

但我们也有一个简单的方法 git add-ing 所有 文件:我们 运行 git add .git add *git add -A 或其他。这告诉 Git 在 one swell foop 中添加 所有 个文件。但是有些文件我们不想添加:那些我们曾 git status 闭嘴的文件。所以该文件应该被称为 .git-do-not-whine-about-these-untracked-files-in-git-status-output-and-do-not-auto-add-them-when-I-use-an-en-masse-style-git-add-operation,或类似的名称。

这些文件并不是字面上的 忽略 ,因为 Git 的索引 中的任何文件都将 下一次提交。所以 .gitignore 显然是错误的名字。但是正确的名称太长而且输入起来很荒谬:我们不妨称这个文件为 .gitignore.

不过,.gitignore 还有另外一件事,那就是列出 跟踪的 文件——具体来说,任何将 成为 在签出一些提交时跟踪文件——可能很危险。 .gitignore 列表所做的是给予 Git 权限,在某些情况下,4 可以 覆盖或删除 文件,破坏您可能没有保存在任何地方的数据。这就是为什么我不喜欢你有一个文件被跟踪,但也匹配 .gitignore 模式的情况:它会让你稍后意外丢失数据。

(这意味着完整的正确名称可能真的是.git-do-not-whine-about-these-untracked-files-in-git-status-output-and-do-not-auto-add-them-when-I-use-an-en-masse-style-git-add-operation-but-do-feel-free-to-clobber-them。那是......更荒谬。)


3当您处于冲突合并的中间时,所有这些都会改变。 git status 文档正在为 Git 的下一个版本进行改进,以帮助更好地理解状态代码字母。不过我不会在这里详细介绍。

4关于这个问题的很长 wide-ranging 的讨论,例如,参见 this thread from the Git mailing list archives。当您 git merge 一个包含配置文件的提交不在您的 current 提交中,但 并且 .gitignore 文件中列出。