TL;DR:你的问题实际上很简单,只要你的 Git 至少是 2.15 版:只要正确使用 git worktree add,创建两个使用相同远程跟踪名称的分支作为上游。
如果不是,您使用两个存储库的方法可能是最好的。只要避免出现一个主要问题(我将在下面讨论),您仍然可以将 git worktree add 用于 2.5 和 2.15 之间的版本。
长
我希望当我在本地分支之间切换时,即使是一个分支的未暂存更改也不应该在另一个分支中可见。
Git 不支持这种期望。
这里真正的问题是没有“未分级更改”之类的东西,也没有“分级更改”之类的东西。您所看到的两者都是动态创建的幻觉,因为这种幻觉往往对人类程序员更有用。 Git 向您显示的更改 是通过比较三个项目中的两个按需计算得出的:当前提交、索引 和工作树。但实际上,只有文件,存储在工作树和索引中,它们是无常且可变的;加上 commits,存储在存储库中,它们是永久的——嗯,大部分是永久的——并且永远冻结。请参阅我最近对Why does output differ under git diff vs. git diff --staged? 的回复,了解更多信息。
存储库中有(可能)许多提交,但每个存储库只有一 (1) 个工作树 + 索引对。1 您可以添加更多索引和工作对-tree 使用git worktree add,您已经尝试过。这应该适用于您的情况,只要您的 Git 至少是 2.15 版本(从 Git 2.5 到但不包括 Git 2.15,git worktree add 有一个潜在的严重错误,具体取决于您使用它的方式)。
1一个裸存储库(使用git clone --bare 或git init --bare 创建)有一个索引和no 工作树,但假设您是不适用于裸存储库。
... git worktree 似乎需要 2 个不同的远程分支
事实并非如此。
git worktree add 所做的是添加一个索引和工作树对。添加的工作树位于单独的工作树目录中(主工作树目录位于主存储库的 .git 目录旁边;.git 目录包含所有索引以及Git 需要的所有其他辅助信息)。添加的工作树也带有自己的HEAD,但共享所有分支名称和远程跟踪名称。
git worktree add 施加的约束是每个工作树都必须为其HEAD 使用不同的分支名称,或者根本不使用分支名称。为了正确定义它是如何工作的,我们需要对HEAD 和分支名称进行题外话。我稍后会谈到这个,但首先,让我们
注意:没有远程分支这样的东西。 Git 确实有一个术语,它称为远程跟踪分支名称。我现在更喜欢称这些远程跟踪名称,因为它们缺少分支名称所具有的一个关键属性。 远程跟踪名称通常类似于origin/master 或origin/develop:即以origin/ 开头的名称。2
2您可以定义多个遥控器,或者将您可能已经拥有的一个遥控器的默认名称更改为origin 以外的名称。例如,您可以添加第二个名为 upstream 的遥控器。在这种情况下,您可能还拥有upstream/master 和/或upstream/develop。这些都是远程跟踪名称的有效缩写形式。
提交、分支名称和 HEAD
任何 Git 存储库中的永久存储单元都是提交。正如您现在所看到的,提交是由一个大的、丑陋的、明显随机的(完全不是随机的)、每个提交唯一的哈希 ID 来标识的,例如5d826e972970a784bd7a7bdf587512510097b8c7。这些东西对人类没有用,我们通常只通过剪切粘贴或间接使用它们,但哈希 ID 是真实名称。如果您有5d826e972970a784bd7a7bdf587512510097b8c7(Git 存储库中的一个提交),它总是那个特定的提交。如果您没有它,您可以获取 Git 存储库的副本(或更新您现有的副本),现在您确实拥有它,它就是 提交——它是 Git 版本 2.20。 (名称v2.20.0 是这个提交更人性化的名称,也是我们通常使用的名称。Git 存储标签名称到哈希 ID 的转换表,这就是 v2.20.0 成为人类的方式-此提交的可读名称。)
提交包含在有人指示 Git 进行提交时索引中的所有文件的完整快照。然而,它也包含一些额外的元数据——关于提交的数据,例如谁做了它,什么时候,为什么(用户名,电子邮件地址、时间戳和日志消息)。在同一个元数据部分中,Git 存储了 previous 提交的确切哈希 ID。 Git 将先前的提交称为提交的 父项。
通过这种方式,存储库中的每次提交都会连接回同一存储库中的较早提交。这是存储库中的历史记录:提交字符串,从结尾开始,然后向后工作。在非常简单的情况下,例如在一个非常新的存储库中,我们可能只需要在一个非常简单的行中进行一些提交,如下所示:
A <-B <-C
在这里,大写字母代表实际的哈希 ID(请记住,它很大、丑陋且显然是随机的)。我们——和/或 Git——将做的是从 end 开始,在提交 C,然后向后工作。 Commit C 存储了提交其父 B 的实际哈希 ID,因此我们可以从 C 找到 B。同时B存储了父A的哈希ID。由于A 是第一个提交,它没有 父级,这就是Git 告诉我们已经到达历史起点的方式:无处可去。
不过,诀窍在于我们需要找到提交 C,其哈希 ID 显然是随机的。这就是分支名称的用武之地。我们选择像master这样的名称,并用它来存储C的实际哈希ID:
A <-B <-C <--master
我们之前提到过,一旦提交,就永远不会改变。这意味着我们实际上并不需要绘制所有内部箭头:我们知道提交无法记住它的子级,因为在我们提交时它们并不存在,但是提交可以记住它的父母,因为父母当时确实存在。 Git 将永远冻结父哈希到新的提交中。因此,如果我们想向我们的三个字符串 A-B-C 添加一个新的提交,我们只需这样做:
A--B--C--D
为了记住 D 的哈希 ID,Git 立即将 new 提交的哈希 ID 写入名称 master:
A--B--C--D <-- master
所以提交一直是固定的,但是分支名称一直在移动!
现在,假设我们添加一个新的分支名称,develop。分支名称,在 Git 中,必须指向恰好一个提交。我们希望它指向的一个提交可能是最新的,D:
A--B--C--D <-- develop, master
请注意,两个名称都指向同一个提交。这是完全正常的!所有四个提交都在两个分支上。
现在让我们添加一个新的提交,并将其命名为E:
A--B--C--D
\
E
我们应该更新两个分支名称中的哪一个?这就是HEAD 的用武之地。
在创建 E 之前,我们告诉 Git 要将 HEAD 附加到哪个名称。我们使用git checkout 来执行此操作。如果我们git checkout master,Git 会将HEAD 附加到名称master。如果我们git checkout develop,Git 会将HEAD 附加到名称develop。让我们在创建E 之前先做后者,这样我们就可以开始:
A--B--C--D <-- develop (HEAD), master
现在我们将创建E,Git 将更新HEAD 所附加的名称,即develop:
A--B--C--D <-- master
\
E <-- develop (HEAD)
简而言之,这就是树枝的生长方式。 Git 创建一个新的提交,其父提交是当前提交,通过名称HEAD 找到,该名称附加到某个分支名称。在创建新的提交之后——这给了它一个新的、唯一的、又大又丑的哈希 ID——Git 将新提交的新哈希 ID 写入同一个分支名称,以便分支名称现在指向新提交。新的提交继续指向旧的提交。
添加的工作树要求您将其 HEAD 附加到不同的分支
出于稍后会理解的原因,git worktree add 要求新添加的工作树为该工作树的HEAD 使用不同的分支名称。也就是说,当我们绘制提交和分支名称并将HEAD 附加到某个分支名称时,我们实际上是在附加this 工作树的HEAD,因为现在有不止一个@987654391 @。
现在我们有了两个名称,master 和 develop,我们可以使用这两个不同的分支名称创建两个不同的工作树:
A--B--C--D <-- master (HEAD) # in work-tree M
\
E <-- develop
对比:
A--B--C--D <-- master
\
E <-- develop (HEAD) # in work-tree D
工作树的内容及其索引通常会与其HEAD 提交的内容匹配。我们将修改工作树中的一些文件,git add 它们到该工作树的索引,git commit 在那里,并更新该工作树的HEAD。这就是为什么这两个需要使用不同的分支名称。观察我们在工作树 M(对于 master)中工作时会发生什么。我们开始:
A--B--C--D <-- master (HEAD) # in work-tree M
\
E <-- develop
索引和工作树匹配提交D。我们做了一些工作,git add 和 git commit 进行新的提交。新提交的哈希 ID 是新的且唯一的;我们这里叫它F,把它画进去,更新名字master:
A--B--C--D--F <-- master (HEAD) # in work-tree M
\
E <-- develop
现在让我们导航到另一个工作树(D 表示开发,但这听起来很像提交D,所以让我们停止这样命名它)。这个有自己的HEAD所以图片是:
A--B--C--D--F <-- master
\
E <-- develop (HEAD)
请注意,master 已更改 - 分支名称 在所有工作树之间共享 - 并且出现了新的提交 F,因为提交也是共享的。但是develop 仍然指向提交E,并且我们这里的索引和工作树,在这个工作树中,与E 的匹配。现在我们修改一些文件,git add 将它们复制回索引,git commit 进行新的提交,我们可以调用G:
A--B--C--D--F <-- master
\
E--G <-- develop (HEAD)
提交G 将出现在另一个工作树中,另一个工作树的develop 将标识提交G,但是由于另一个工作树有master / 提交F 已选中-出,其他工作树的索引和工作树仍将匹配提交F。
任何分支名称的上游设置都是你控制的
当您使用git checkout -b 或git branch 创建新 分支名称时,您 控制:
- 新分支是否有任何上游设置,以及
- 如果是这样,什么名称——
origin/whatever 是典型的,但它可以是任何名称——存储在该设置中。
您的master 使用origin/master 作为其上游名称,而您的develop 使用origin/develop 作为其上游名称是很正常的,但这里根本没有任何限制。例如,您可以将 all 您的分支 share origin/master 作为其上游。或者,您可以拥有具有 no 上游集的分支。有关上游设置的讨论,请参阅 Why do I have to "git push --set-upstream origin <branch>"?。
有一个神奇的默认值:
$ git checkout feature-xyz
将尝试检查您现有的 feature-xyz 分支。如果没有feature-xyz 分支,你的Git 将检查你所有的远程跟踪名称,例如,看看是否有origin/feature-xyz。如果是这样,您的 Git 将创建您自己的feature-xyz,指向与origin/feature-xyz 相同的提交,并将origin/feature-xyz 设置为其上游。这是为了方便。如果不方便,请不要使用它:改用-b。
git worktree add 命令与 git checkout 共享这个特殊技巧:两者都有一个 -b 来创建一个 new 分支(不这样做),并且都默认尝试检查一些现有 分支。因此,对于这种特殊情况,两者都会自动创建一个带有上游集的新分支。
分离的 HEAD 和添加的索引,以及 Git 2.5 到(但不包括)2.15 中的错误
在 Git 中,分离的 HEAD 仅表示 HEAD 未附加到分支名称。请记住,绘制正在发生的事情的常用方法是将HEAD 附加到某个名称:
...--F--G--H <-- master (HEAD)
我们可以改为让 Git 指向 HEAD直接指向提交,而无需通过分支名称:
...--F--G <-- HEAD
\
H <-- master
在这种模式下,如果我们进行 new 提交,Git 会将新提交的哈希 ID 写入 HEAD 本身,而不是 HEAD 未附加的名称:
...--F--G--I <-- HEAD
\
H <-- master
添加的工作树可以始终处于分离 HEAD 模式,但在 Git 2.5 版中存在一个可怕的错误,其中首次引入了 git worktree,直到 Git 2.15 版才修复。
具体来说,每个添加的工作树都有自己的 HEAD 和自己的私有索引文件。由于 Git 其余部分的工作方式,这是必要的:HEAD 记录有关 this 工作树的信息,索引是 this 工作树的索引,所以它们都是一大组项目。不幸的是,Git 的 垃圾收集器 git gc 没有被正确地教导尊重添加的工作树。
垃圾收集器的工作是查找未引用(未使用/不需要)的 Git 对象 — 看起来像存储库中剩余垃圾的 blob、树、提交和带注释的标签。 Git 使用这一点,以便 Git 命令可以在需要时创建这些不同的内部对象,而不必担心它们是否真的必要,并且不必采取任何特殊操作来处理被中断(通过,例如,CTRL+C,或网络会话断开)。其他正常的日常 Git 操作,包括git rebase,可能会产生这种垃圾。这一切都很好,很正常,因为看门人git gc 会定期清理它。
但是您使用分离的 HEAD 所做的任何 新 提交都只有 HEAD 本身引用它们。在 main 工作树中,这不是问题:gc 管理员检查 HEAD 文件,查看引用,并且知道不删除这些提交。但git gc 不会检查添加的额外 HEAD。因此,如果您添加了带有分离 HEAD 的工作树,则分离的 HEAD 对象可能会消失。类似的规则适用于 blob 对象,如果存储在添加的工作树索引中的 blob 对象仅从该索引中引用,git gc 可能会删除基础 blob 对象。
有一个二级保护:git gc 默认不会修剪任何小于 14 天的对象。这给了所有 Git 命令 14 天的时间来完成他们的工作,然后看门人过来并将他们正在进行的对象扔进办公室后面的垃圾箱。所以这一切在主工作树中都可以正常工作,并且在 Git 2.15 及更高版本中添加的工作树中工作正常。但是对于中间 Git 版本,git gc 可能会出现,看到一个 14 天或更长时间的提交、树或 blob,不应该因为添加了工作树而被丢弃,却没有意识到并把它扔掉。
如果您没有分离的 HEAD 并且在 14 天内小心添加并提交,则不会出现此错误。如果您禁用垃圾收集,它也不会发生,但这通常不是一个好主意:Git 依赖 gc 来清理和保持良好的性能。而且,当然,它在 Git 2.15 中已修复,所以如果你有这个或更高版本,你就可以了。它只影响添加的工作树,因此请在 2.5 和 2.15 之间谨慎使用。