新的工作树和分支有什么区别?

What is the difference between a new working tree and a branch?

我是 git 新手,我对新的工作树和分支有点困惑。看完docs还是一头雾水所以决定问一下。

我理解 Git 分支就像当前工作树中的新 'branch'。如果我想开发一个新功能,我可以从 Main 分支签出到我新创建的分支,开发该功能,然后 merge/rebase 将其放入 Main 分支。

我想确认一下我的理解:同一存储库中新工作树的概念是我们创建一个全新的工作space/tree(有一个单独的主分支)。在那棵树上,我可以有很多新的分支?

如果你能指出一个例子(在 Github 上),我将不胜感激,该例子表明回购协议由不止一个工作树组成,以便我可以进一步研究。

谢谢!

创建工作树是区分分支中跟踪的提交的一种方式,对修补程序很有用。当您创建工作树时,将自动创建一个新分支。好处是您当前的应用程序版本仍将在您的本地环境版本上,您不必将开发恢复到以前的版本以将更改推送到。

例如,您正在将应用程序从 MySQL 迁移到 Postgres,但更改尚未准备好用于生产。报告了一个需要尽快修补的错误。您的开发环境不再有 运行 MySql 实例。如果您创建一个新的工作树并修补应用程序,只有该树中的 changes/commits 将被推送到 Main,而不是数据库提供程序中的更改。

这里一个区别,它很重要,但是理解它是棘手的。为此,让我们从这个开始:Git,最后,就是 提交 。这与 分支 无关,但 分支名称 帮助我们(和 Git) find 提交.它也与 files 无关,尽管每个提交都包含文件。这实际上是关于 提交 。因此,我们需要从提交是什么以及它为您做什么开始。然后我们将继续 分支名称 以及它们如何 find 提交,以及 Git 的 index 和你的 工作树 。一旦我们正确地涵盖了所有这些内容,我们就可以介绍 git worktree 及其特点。

提交

在 Git 中的提交存储了两件事:

  • 它有每个文件的完整快照,一直冻结,按照您告诉[=544=时文件的格式] 制作快照。 (稍后我们会看到这些文件的实际来源:令人惊讶的是,它不是您看到和使用的文件 / on。)这些文件存储在特殊的 read-only、Git-only 中压缩和 de-duplicated 格式,只有 Git 本身可以使用。 de-duplication 处理这样一个事实,即每个 new 提交通常与一些旧的现有提交具有大部分相同的文件。这些文件实际上是在提交之间共享的——这是完全安全的,因为任何提交的任何部分都不能被更改。

  • 除了快照,每个提交都有一些元数据。元数据包含诸如提交人和提交时间之类的信息。您还可以在此处放置一条日志消息,解释 为什么 您进行了提交,以便其他人(或您未来的自己)可以回来查看您的意图。 (最好对上一次提交的错误以及这次提交的改进措施进行高级解释,以防您以后发现错误。没有必要输入 low-level 详细信息个别更改的行,因为 git diffgit show 可以机械地显示。)像快照一样,此元数据也一直冻结 - 事实上,这是每个内部 Git object:none 可以 永远被改变 ,甚至不能被 Git 本身改变。

现在,要直接找到一个提交,Git需要知道提交的哈希ID。提交哈希 ID 是大丑陋的字符串,代表一个大的 hexadecimal number, which is actually a cryptographic checksum of the contents of the commit. (This is why the contents can't change: changing even a single bit changes the checksum. Git makes sure, during extraction, that the contents still checksum to the key used to find the object. The objects themselves are stored in a key-value database,键是校验和。)

因此,我们需要一些哈希 ID H 来查找提交。 git log 命令显示哈希 ID(完整或缩写,取决于您选择的日志格式)。但是这些东西对人来说是没用的,人往往分不清211eca0895794362184da2be2a2d812d070719d3 21127fa9829da1f7b805e44517970194490567d0 例如。 Git可以;计算机擅长这种挑剔的细节。因此,每个提交都会在其元数据中存储一个或多个 较早 提交的原始哈希 ID。

大多数提交只存储一个 earlier-commit 哈希 ID。这构成了一个 backwards-looking 提交链。也就是说,如果我们以某种方式知道 latest 在我们的 mastermain 分支上提交的哈希 ID 是 HH 这里代表一些真实的,虽然丑陋的哈希 ID),我们可以将该原始哈希 ID 提供给 git log。 Git 然后会查找提交 H,并且提交 H 在其元数据 中有 一些较早提交的哈希 ID G:

          G <-H

但是 G 也是一个提交,所以它有元数据。 Git 可以使用刚刚从 H 获得的哈希 ID 和 that 元数据从所有提交的大数据库中提取整个提交 G存储一些 still-earlier 提交的哈希 ID:

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

git log 命令可以让这个过程永远保持下去,或者更确切地说,只要它发现的每个提交都有一些 previous 提交。该链条最终在历史的开始处结束:在第一次提交时,即第一次提交,其中没有以前的提交哈希 ID,因为它不能。

因此,我们至少需要开始的就是以某种方式神奇地知道 最新 提交 H 的哈希 ID。从那里,Git 可以向后工作,一次提交一个。这些提交字面上 存储在存储库中的历史记录。每个提交都有一些元数据,告诉他们是谁、何时以及为什么提交;每个提交都有 next-earlier 提交的哈希 ID。每次提交都是截至该提交时整个文件集的完整快照。并且,通过 比较 任何两个相邻提交的内容, Git 可以告诉我们该提交中 更改了 的内容。但是我们确实遇到了一个问题:我们需要知道 最新 提交的哈希 ID。

分行名称

这就是分支名称的来源。像 mastermain 这样的分支名称只包含 一个提交哈希 ID 。存储在 中的哈希 ID 该名称是肯定存在于大型 Git object 数据库中的某个提交的名称,并且根据定义,该哈希 ID 告诉Git 该提交是 最后一个 提交 in/on 该分支:

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

现在,仅仅因为 Hmain 上的最后一次提交并不意味着没有任何提交可能会在 H“之后”。例如,现在让我们创建一个新的分支名称 develop,并使其 暂时也指向提交 H

...--F--G--H   <-- develop, main

无论我们给git checkoutgit switch取什么名字,Git都会提取提交H。毕竟,两个名字 select 都提交 H。事实上,两个分支都包含相同的提交集。但是 Git 需要知道我们正在使用哪个 name,所以让我们添加一个特殊的名称,HEAD,这些图纸:

...--F--G--H   <-- develop, main (HEAD)

在这里,HEAD 是“附加到”main。这意味着我们在分支 main 上,使用提交 H。如果我们 运行 git checkout developgit switch develop,Git 将从提交 H 切换到提交 H——这不是一个很大的切换!——但是现在会将 HEAD 附加到名称 develop:

...--F--G--H   <-- develop (HEAD), main

从这里开始,让我们进行一次 new 提交。 Git 将给这个新提交一个新的 universally-unique 哈希 ID。1 我们就称它为 I,并像这样绘制它:

...--F--G--H   <-- main
            \
             I   <-- develop (HEAD)

提交 I 将向后指向其先前的提交 H,我们从那里发展 I,并且——这是偷偷摸摸的技巧——现在 Git 写入 I 的哈希 ID 到 name develop。这推进了分支。现在 develop 包含提交 I 作为其最终提交,以及直到 H 的提交; main 仍以 H 结束。

这就是分支通过新提交增长的方式:每次我们进行新提交时,分支名称 也会前进。如果我们在 develop 上进行两次提交,我们得到:

...--F--G--H   <-- main
            \
             I--J   <-- develop (HEAD)

如果我们回到 main,并创建一个新的分支名称,或者直接在 main 上进行提交,我们发现这两个分支会分叉:

             K--L   <-- main (HEAD)
            /
...--F--G--H
            \
             I--J   <-- develop

如果我们认为直接在 main 上进行这些提交是一个坏主意,我们现在可以创建一个 new 分支以指向 L :

             K--L   <-- main (HEAD), feature
            /
...--F--G--H
            \
             I--J   <-- develop

然后强制Git将名称main移回H(我们必须找到H 的哈希 ID,可能带有 git log;然后我们可能会使用 git reset --hard 移动 main):

             K--L   <-- feature
            /
...--F--G--H   <-- main (HEAD)
            \
             I--J   <-- develop

这里强调的是分支名称移动。通常,它们通过使用 git commit 添加新的提交到分支来移动。但是您可以任意移动它们,例如使用 git resetgit branch -f。 Git 简单地 使用 分支名称来 找到提交 .

(如果你向后移动一些分支名称,一些提交会变得很难找到。例如,假设我们强制 develop 回到提交 I。我们如何找到提交 J 在那之后?有一些方法,但它变得混乱,所以每当你强制使用分支名称时,你应该首先仔细考虑。)


1这里的“普遍唯一”是指这个hash ID不仅不会出现在thisGit仓库中然而,它现在、过去或将来也不在 任何其他 Git 存储库 中! Git 实际上不必满足这个强约束 - 新的哈希 ID 只需要 not-exist 在任何 Git 存储库中,这个 Git 存储库将永远“满足”,除非两个会议 Git 共享 this 提交——但是 Git 无论如何都会尝试实现它,因为我们不确定哪个 Git 我们将来会连接在一起的存储库。这就是哈希 ID 如此大且丑陋的原因。 (事实证明,它们现在还不够大和丑。它们是在 2005 年首次发布 Git 时,但时间在前进。)


Git 的索引和您的工作树

我多次注意到 commits 中的文件都是 read-only 和 Git-only 并且根本无法使用。因此,当您签出 提交时,Git 必须将这些文件复制出来,变成您的计算机可以处理的形式正常使用。这些副本进入您的 工作树 。这非常简单易懂,真的:提交包含一个存档,必须是 de-archived 才能使用。所以 git checkoutgit switch 这样做:它删除以前的结帐(如果有的话),并将其替换为您选择的提交,这是某个分支名称 selects.

(在实践中,它实际上比这更高级、更复杂。首先,为了快速,签出过程尽量不接触任何它不需要的文件;其次,为了void 丢弃未提交的工作,它必须检查以确保 remove-and-replace 的文件中 none 有未提交的工作。但是“删除旧提交,放入新提交”作为您头脑中的起始模型很好。)

我之前也提到过,git commit 实际上并没有根据您在工作树中的内容制作新快照。相反,Git 为每个文件插入一个额外的“副本”——在引号中,我稍后会解释, 当前提交的版本和你的工作树副本之间.这个额外的“副本”在 Git 不同的地方,index,或者 staging area,或者——现在很少见了—缓存。这三个名字指的是同一个东西,我在这里称之为“索引”。

索引中的内容——至少在最初,在一个新的check-out之后——是每个文件的预压缩和de-duplicated“副本”当前提交。由于这些都是 in 当前提交,索引根本不包含任何实际文件副本,只是对 ready-to-re-commit 文件的引用。

在您工作时,Git 希望您在工作树中更改的每个文件上 运行 git add。当你运行git add,Git:

  • 检查文件是否已在索引中:如果是,则“复制”被启动;
  • 压缩和 de-duplicates 文件的工作树副本,并将 that 放入其索引中。

所以任何文件现在都在索引中,任何更新文件的索引副本都换成了新的,ready-to-commit,压缩和 de-duplicated 数据。

当您 运行 git commit、Git 只是 打包索引 以用作新的提交快照。这意味着该索引的作用是作为您提议的下一个快照。它开始与当前提交匹配,但随着您的变化 运行 git add.

这就是为什么索引的名称之一是 暂存区: 当您更新文件时,您将更新的文件安排在“舞台上”,准备好进行快照。你的工作树中的副本是你的大惊小怪,除了:

  • 当您使用 git checkoutgit switch 由于切换到其他提交而覆盖它们时;
  • 当你使用其他Git命令故意覆盖它们时,例如,丢弃一个没有成功的实验;和
  • 当您使用 git add 告诉 Git 时:从工作树复制回您的索引,为下一次提交准备好新副本 .

Git 索引中的“副本”(pre-de-duplicated)供 Git 在下一次提交中使用.

(索引在 git merge 操作期间扮演了一个扩展的角色,尽管我们不会在这里介绍它。这个扩展的角色就是为什么“索引”可能比“暂存区”更好的名称.但是“暂存区”是一个很好的名称,可以说明它在这种情况下的工作方式。)

到目前为止的总结

  • 一个分支名称 select一些特定的提交,由于指向那个提交。
  • 进行新提交会导致当前分支名称指向新提交。新提交的 parent 是您刚刚在进行新提交之前签出的提交;现在新的提交就是您签出的那个。
  • git checkoutgit switch 命令选择一个新的分支名称 and/or 提交以检出。 (您可以使用 detached HEAD 模式选择原始提交,但我们不会在此处介绍。)假设您使用新的分支名称,Git 现在将附加 HEAD 那个分支名,这样就变成了当前分支名.
  • 每个提交都有一个唯一的、又大又丑的哈希 ID 号,并且每个提交(除了第一个明显的例外,以及任何其他 so-called root commits) 向后指向其 parent。虽然我们没有在这里讨论这一点,但 merge commit 的特殊之处在于它指向两个或多个 parent,而不是仅仅一个 parent。每次提交(包括每次合并提交)都会存储您(或任何人)提交时 Git 索引中所有文件的完整快照。
  • 工作树 保存来自提交并进入Git 索引的文件,然后进入工作树。您现在可以随心所欲地修改这些文件。在您 git add 它们或使用其他一些 Git 命令替换工作树内容之前,这里的一切都是您的。

添加 work-tree 和 git worktree add

上图中,有:

  • 正好一个HEAD,哪个分支是 当前 checked-out 分支 ;
  • 恰好一个索引;和
  • 正好是一棵工作树

这意味着如果我们正在开发一些新功能,并且一些非常重要的 must-be-fixed-immediately bug 出现,我们有至:

  • 保存我们所有的 in-progress 工作;
  • 切换到需要重要的分支bug-fix;
  • 因此破坏了 (a) 我们的思路和 (b) 我们在工作树中进行的任何未提交的工作,或者需要很长时间编译或其他任何工作。

如果这是一个问题,我们可以再次克隆源存储库。这得到了一个完全独立的存储库,其中我们有所有相同的 提交 — 哈希 ID 匹配,因为它们实际上是 相同的 提交 — 但是我们有一个新的工作树、索引和 HEAD。但是,如果我们能够避免 运行 git clone 并抛出大量磁盘 space and/or 网络时间 and/or 无论它需要多少其他资源,这可能会很好这个。

如果我们可以在不影响我们的现有工作树的情况下添加一个新工作树怎么办?我们还需要一个新的 index 和新的 HEAD 这样这个额外的工作树可以有一个 不同的分支在其中检出 .

这正是 git worktree add 所做的。我们告诉Git:给我创建一个新的工作树,并检查其中的一些分支。 Git 生成所有三样东西——HEAD、索引和工作树——并在那里做一个 git switchgit checkout 来填充 that 来自 的工作树 提交为 select 由其他分支名称编辑。

虽然有一个 odd-seeming(起初)约束:我们的新工作树 必须与我们的主要工作树或任何其他添加的工作树位于不同的分支 树。一旦您考虑 git commit 如何自动 更新 无论哪个分支名称被签出,原因就会变得更清楚。作为练习,想一想如果两个工作树都检查了分支 develop,然后您在其中一个树中进行了 new commit,会发生什么情况。其他工作树中的文件和 Git 的索引会怎样? (我会为你回答这个问题:没有 发生在他们身上。)那么,当你进入另一个工作树并尝试进行另一个新提交时会发生什么?

并没有试图让这一切都起作用(人们可以想象各种方法让它起作用),Git 只是完全禁止它,这样问题就不会在第一时间出现。所以这就是为什么存在这个奇怪的限制。此限制不适用于 detached-HEAD 模式,因此添加的工作树可以在任何特定提交时处于分离 HEAD 模式,但同样,我们在这里并未真正涵盖分离 HEAD 模式。

回答您的一些具体问题

I understand that a Git branch is like a new 'branch' in the current working tree.

不:工作树只是您工作的地方。术语 branch 有歧义(参见 What exactly do we mean by "branch"?),在 Git 中,但是 branch name,如 maindevelop,只是我们(和 Git)用来查找一个特定哈希 ID 的名称。我们可以使该名称指向我们喜欢的任何提交。每个存储库都有自己的名称:如果我克隆您的 GitHub 存储库,我就有自己的分支名称。当您克隆 GitHub 存储库时,您会在克隆中获得自己的分支名称。 GitHub 存储库有 它的 自己的分支名称。这些名称中的 None 必须匹配:如果我愿意,我可以在本地调用它 niam 的同时使用您的 main(尽管这很愚蠢,只会给我带来额外的工作)。

The concept of a new working tree in the same repository is that we create a completely new working space/tree (with a separate Main branch).

否:我们在我们的存储库克隆中创建了一个新的工作树,但共享其他所有内容。2 特别是,所有 分支名称 共享。这一切都在一个克隆(一个存储库)中。

I would really appreciate it if you can point to an example (on Github) that the repo consists of more than one working tree so I can study further.

这实际上是不可能的:首先,添加的 work-tree 是特定于每个克隆的。此外,GitHub 存储库是 so-called bare 存储库,根本没有工作树(因此没有人可以直接在 GitHub,并不是说我们首先登录 GitHub。


2除了已经列举的index和HEAD,还有某些work-tree-specific special refs,比如ORIG_HEAD, MERGE_HEADCHERRY_PICK_HEAD 等。当我写这篇文章时,我突然想到我不确定 FETCH_HEAD 是否特定于 work-tree。像二分法这样的特殊参考也是 work-tree-特定的。这里的细节比较乱ad-hoc;原始实现中遗漏了一些细节。