我怎样才能在同一个远程分支上有 2 个独立的本地 Git 分支

How can I have 2 independent local Git branches off same remote branch

我有一个 master 分支,正在处理涉及相同文件的 2 个功能。我希望有 2 个本地分支指向同一个上游主控但有不同的更改。我不想在本地提交更改,以便 IDE 保留格式,如
中的边框阴影 https://d3nmt5vlzunoa1.cloudfront.net/idea/files/2018/10/k8sCompletion.png

我一直无法使用 git 结帐成功,因为当我在一个分支中进行更改并切换到另一个分支时,未暂存的更改在它上面也可见。我想出的解决方案是在 2 个存储库中检查我的代码,因为 git worktree 似乎需要 2 个不同的远程分支。然而,这意味着硬盘效率低下。有没有办法实现我想要的?

我希望当我在本地分支之间切换时,即使一个分支的未暂存更改在另一个分支中也不应该可见。

TL;DR:如果您的 Git 至少是 2.15 版,您的问题实际上很简单:只需正确使用 git worktree add,创建两个使用相同 remote-tracking 的分支名称作为他们的上游。

如果没有,您使用两个存储库的方法可能是最好的。只要避免一个主要问题(我将在下面讨论),您仍然可以对 2.5 和 2.15 之间的版本使用 git worktree add

I expect that when I switch between local branches, even the unstaged changes of one branch should not be visible in the other.

Git不支持此期望。

这里真正的问题是没有"unstaged changes"这样的东西,也没有"staged changes"这样的东西。您所看到的两者都是一种幻觉,是在飞行中创造的,因为这种幻觉往往 对人类程序员更有用 。 Git 显示的 changes 是按需计算的,通过比较三个项目中的两个:当前提交index,以及work-tree。但实际上,只有 文件 存储在 work-tree 和索引中,它们是无常且可变的;加上存储在存储库中的 提交 ,它们是永久性的——好吧,大部分是永久性的——并且一直冻结。有关更多信息,请参阅我最近对 ​​ 的回答。

存储库中(可能)有很多提交,但每个存储库只有一 (1) 个 work-tree + 索引对。1 您可以添加更多使用 git worktree add 的索引和 work-tree 对,您已经尝试过了。这应该适用于您的情况,只要您的 Git 至少是版本 2.15(从 Git 2.5 到但不包括 Git 2.15,git worktree add 有一个 potentially-serious 错误,具体取决于您如何使用它)。


1一个裸仓库(使用git clone --baregit init --bare创建)有一个索引并且没有work-tree,但假设您没有使用裸存储库似乎是安全的。


... git worktree seems to require 2 different remote branches

不是这样的

git worktree add 所做的是添加索引和-work-tree 对。添加的 work-tree 位于单独的 work-tree 目录中(主 work-tree 目录紧挨着主存储库的 .git 目录;.git 目录包含 所有 索引以及 Git 需要的所有其他辅助信息)。添加的 work-tree 也带有自己的 HEAD,但共享所有 分支名称 remote-tracking 名称.

git worktree add 施加的约束是每个 work-tree 必须为其 HEAD 使用不同的分支名称,或者根本不使用分支名称。为了正确定义它是如何工作的,我们需要题外话 HEAD 和分支名称。我稍后会谈到这个,但首先,让我们

注意:没有远程分支这样的东西。 Git 确实有一个术语叫做 remote-tracking 分支名称。我现在更喜欢将这些 remote-tracking 命名为 ,因为它们缺少分支名称所具有的一个关键 属性。 remote-tracking 名称 通常看起来像 origin/masterorigin/develop:即以 origin/.[=334= 开头的名称]2


2您可以定义多个遥控器,或者将您可能已经拥有的一个遥控器的默认名称更改为 origin 以外的名称。例如,您可以添加一个名为 upstream 的第二个遥控器。在这种情况下,您可能还会有 upstream/master and/or upstream/develop。这些都是 remote-tracking 名称的有效缩写形式。


提交、分支名称和 HEAD

任何Git 存储库中的永久存储单元是提交。正如您现在所见,提交是由一个又大又丑的 apparently-random(完全不是随机的)、unique-to-each-commit 哈希 ID 标识的 [=48] =].这些东西对人类没有用,我们通常只通过 cut-and-paste 或间接使用它们,但哈希 ID 是真实姓名。如果您有 5d826e972970a784bd7a7bdf587512510097b8c7(在 Git 存储库中为 Git 提交),它是 always 那个特定的提交。如果您没有,您可以为 Git 获取 Git 存储库的副本(或更新您现有的副本),现在您 它,它是 那个 提交——它是 Git 版本 2.20。 (名称 v2.20.0 是此提交的更多 human-oriented 名称,也是我们通常使用的名称。Git 存储标签名称到哈希 ID 的翻译 table,这就是 v2.20.0 成为此提交的 human-readable 名称的原因。)

一次提交包含 在有人指示 Git 进行提交时索引中的所有文件 的完整快照。但是,它 包含一些额外的 元数据 — 数据 关于 提交,例如提交人,时间和原因(用户名、电子邮件地址、时间戳和日志消息)。在同一个元数据部分,Git 存储 previous 提交的确切哈希 ID。 Git 调用之前的 com它是提交的 parent

通过这种方式,存储库中的每一次提交 都会连接回同一存储库中较早的提交。这是存储库中的历史记录:提交字符串,从末尾开始,向后计算。在非常简单的情况下,例如在一个非常新的存储库中,我们可能只在一个非常简单的行中进行一些提交,如下所示:

A <-B <-C

在这里,大写字母代表实际的哈希 ID(请记住,它们又大又丑 apparently-random)。我们——and/or Git——要做的是从 结束 、提交 C 开始,然后向后。提交 C 存储其父提交 B 的实际哈希 ID,因此我们可以从 C 中找到 B。同时 B 存储父 A 的哈希 ID。由于 A 是第一个提交,它没有 没有 父级,这就是 Git 告诉我们已经到达历史起点的方式:无处可去.

不过,诀窍在于我们需要 找到 提交 C,其哈希 ID 是 apparently-random。这就是 分支名称 的用武之地。我们选择一个像 master 这样的名称,并用它来存储 C:

的实际哈希 ID
A <-B <-C   <--master

我们之前提到过,一旦提交,就永远不会改变。这意味着我们真的不需要绘制所有内部箭头:我们知道提交不能记住它的子项,因为当我们提交时它们不存在,但是提交 可以记住它的parent,因为parent当时确实存在。 Git 将永远冻结父哈希到新提交中。因此,如果我们想向我们的三个字符串 A-B-C 添加一个新提交,我们只需这样做:

A--B--C--D

为了记住D的哈希ID,Git立即将提交的哈希ID写入名称master

A--B--C--D   <-- master

所以提交一直是固定的,但是分支名称一直在移动!

现在,假设我们添加一个新的分支名称,develop。 Git、中的分支名称必须指向恰好一个提交。我们希望它指向的一个提交可能是最新的,D:

A--B--C--D   <-- develop, master

请注意两个名称都指向同一个提交。这是完全正常的!所有四个提交都在两个分支上。

现在让我们添加一个新的提交,并将其命名为 E:

A--B--C--D
          \
           E

我们应该 Git 更新两个分支名称中的哪一个?这就是 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 写入相同的分支名称,这样分支名称现在指向新提交.新提交继续指向旧提交。

添加的 work-tree 要求您将它们的 HEAD 附加到不同的分支

出于稍后会理解的原因,git worktree add 要求新添加的 work-tree 使用 不同的分支名称 work-tree 的 HEAD。也就是说,当我们绘制提交和分支名称并将 HEAD 附加到某个分支名称时,我们实际上是在附加 this work-tree 的 HEAD, 因为现在不止一个 HEAD.

所以现在我们有了两个名字,masterdevelop,我们可以使用这两个不同的分支名称制作两个不同的 work-trees:

A--B--C--D   <-- master (HEAD)    # in work-tree M
          \
           E   <-- develop

对比:

A--B--C--D   <-- master
          \
           E   <-- develop (HEAD)  # in work-tree D

work-tree 的内容及其索引通常会开始匹配其 HEAD 提交的内容。我们将修改 work-tree 中的一些文件,git add 将它们修改为那个 work-tree 的索引,以及那里的 git commit,并更新 that work-tree的HEAD。这就是为什么这两个需要使用不同的分支名称。观察我们在 work-tree M(master)中工作时会发生什么。我们开始于:

A--B--C--D   <-- master (HEAD)    # in work-tree M
          \
           E   <-- develop

索引和 work-tree 匹配提交 D。我们做了一些工作,git addgit commit 来进行新的提交。新提交的哈希 ID 是新的且唯一的;让我们在这里称它为 F,并绘制它,更新名称 master:

A--B--C--D--F   <-- master (HEAD)    # in work-tree M
          \
           E   <-- develop

现在让我们导航到另一个 work-tree(D 代表开发,但这听起来很像提交 D,所以让我们停止这样命名)。这有它自己的 HEAD 所以图片肯定是:

A--B--C--D--F   <-- master
          \
           E   <-- develop (HEAD)

请注意 master 已更改 - 分支名称 在所有 work-tree 之间共享 - 并且出现了新提交 F ,因为提交也是共享的。但是 develop 仍然指向提交 E,我们的索引和 work-tree 在这里,在这个 work-tree 中,与 E 的索引匹配。现在我们修改一些文件,git add 将它们复制回索引,git commit 进行新的提交,我们可以调用 G:

A--B--C--D--F   <-- master
          \
           E--G   <-- develop (HEAD)

commitG会出现在otherwork-tree中,otherwork-tree的develop会标识commitG,但是由于other work-tree 有 master / 提交 F checked-out,另一个 work-tree 的索引和 work-tree 仍然匹配提交 F

任何分支名称的上游设置都由您控制

当您使用git checkout -bgit branch创建new分支名称时,控制:

  • 新分支是否有任何上游设置,以及
  • 如果是这样,什么名字——origin/whatever是典型的,但它可以是任何名字——存储在那个设置中。

你的master使用origin/master作为它的上游名字是很正常的,你的develop使用origin/develop作为它的上游名字是很正常的,但是有这里完全没有限制。例如,您可以将 all 您的分支 share origin/master 作为它们的上游。或者,您可以拥有 no 上游集的分支。有关上游设置的讨论,请参阅

有一个神奇的默认值:

$ git checkout feature-xyz

将尝试检查您现有的 feature-xyz 分支。如果 没有 一个 feature-xyz 分支,你的 Git 将检查你所有的 remote-tracking 名字,看看是否有,例如, origin/feature-xyz。如果是这样,您的Git将创建您自己的feature-xyz,指向origin/feature-xyz相同的提交 ,并将 origin/feature-xyz 设置为其上游。这是为了方便起见。如果不方便,请不要使用它:使用 -b 代替。

git worktree add 命令与 git checkout 共享这个特殊技巧:两者都有一个 -b 创建一个 new 分支(不这样做),两者都默认尝试检查一些 existing 分支。因此,对于这种特殊情况,两者都会自动创建一个带有上游集的新分支。

分离的 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 不是 attached-to:

...--F--G--I   <-- HEAD
         \
          H   <-- master

添加的 work-tree 可以 始终 处于分离的 HEAD 模式,但是 Git 版本 2.5 中存在一个可怕的错误,其中 git worktree首次引入,直到 Git 2.15 版才得到修复。

具体来说,每个添加的 work-tree 都有 自己的 HEAD 和自己的私有索引文件。由于 Git 的其余部分的工作方式,这是必需的:HEAD 记录有关 this work-tree 的信息,索引是 thiswork-tree,所以都是一大组。不幸的是,Git 的 垃圾收集器 git gc 没有被正确教导去尊重添加的 work-trees。

垃圾收集器的工作是找到 未引用(未使用/不需要)Git 对象——blob、树、提交和带注释的标签一个存储库。 Git 使用它,以便 Git 命令可以在需要时创建这些不同的内部对象,而不必担心它们是否 真的 必要,也不必采取任何特殊措施来处理中断(例如,CTRL+C,或网络会话断开)。其他正常的日常 Git 操作,包括 git rebase,也会产生这种垃圾。这一切都很好,很正常,因为看门人 git gc 会定期清理它。

但是任何 new 提交你用分离的 HEAD 做的只有 HEAD 本身引用它们。在 main work-tree 中,这不是问题:gc 看门人检查 HEAD 文件,看到引用,并知道不要删除这些承诺。但是 git gc 不检查 添加的额外 头。因此,如果您添加了一个带有分离 HEAD 的 work-tree,那么分离 HEAD 的对象可能会消失。类似的规则适用于 blob 对象,如果存储在添加的 work-tree 索引中的 blob 对象仅从该索引引用 git gc 可能会删除底层的 blob 对象。

有二级保护:git gc 默认情况下不会修剪任何小于 14 天的对象。这给所有 Git 命令 14 天的时间来完成他们的工作,然后看门人过来将他们的 in-progress 对象扔进办公室后面的垃圾桶。所以这一切在主 work-tree 中工作正常,并且在 Git 2.15 及更高版本中添加的 work-trees 中工作正常。但是对于中间 Git 版本,git gc 可能会出现,请参阅 不应该 的 14-or-more 天前的提交、树或 blob因为加了一个work-tree被扔掉了,还没意识到就扔掉了

如果您没有分离的 HEAD 并且在 14 天内小心 add-and-commit,则此错误不会发生。如果您禁用垃圾收集,它也不会罢工,但这通常不是一个好主意:Git 依赖于 gc 来清理和保持良好的性能。而且,当然,它已在 Git 2.15 中得到修复,因此如果您拥有该版本或更高版本,就可以了。它只影响添加的 work-trees,因此请谨慎使用 2.5 和 2.15 之间的值。

我有这个完全相同的问题。以防万一这里有人和我一样困惑,我没有意识到在功能分支上提交可以解决这个问题!

一旦您在其中一个功能分支上进行了提交,暂存的更改将从另一个功能分支中删除。比方说,您在分支 1 中添加了三行代码。如果您签出分支 2,您将看到这三行代码。但是,如果您先 git 添加并 git 在分支 1 上提交,那么当您签出分支 2 时,您将看不到这些更改。

希望这对某人有所帮助。