Git:如何在我的项目中设置一个与远程分支相同的文件夹?

Posted

技术标签:

【中文标题】Git:如何在我的项目中设置一个与远程分支相同的文件夹?【英文标题】:Git: how do I set a folder in my project equal to that in a remote branch? 【发布时间】:2021-11-30 00:24:00 【问题描述】:

我有一个feature/mine 分支,我想合并到feature/other,但是在开发过程中的某个地方,对仍在feature/mine 中且不应在feature/other 中的特定文件夹进行了更改。因此,我的 PR 中的差异显示了 src/some-folder-I-dont-want-to-change 中的更改,但这些更改是在提交树的深处进行的。我不能简单地还原一些提交。

我想要的只是将我的src/some-folder-I-dont-want-to-change 设置为feature/othersrc/some-folder-I-dont-want-to-change。有没有办法做到这一点?

我尝试了git checkout feature/other -- src/some-folder-I-dont-want-to-change,但这只是从feature/other 添加文件,它不会删除我的分支上但不应该删除的文件。

【问题讨论】:

【参考方案1】:

听起来您正在寻找git restore,请参阅例如https://***.com/a/15404733/6060876.

我相信对于您的用例,您需要编写如下内容:

git restore --source=feature/other --staged --worktree -- src/some-folder-I-dont-want-to-change

【讨论】:

【参考方案2】:

TL;DR

您可能只想从特定提交中检出一些文件,然后再次提交。然后,从合并基础到您的分支提示的差异将不包括对这些文件的任何更改,或者将对这些文件进行 same 更改,具体取决于您的确切需要。这可能会引发未来的问题,因此您需要注意这一点。

您可能希望使用git rebase替换您现有的提交,并使用一些新的和改进的提交。这比较复杂,可能会引发一些眼前的问题,但很可能会解决未来的问题。

您认为您的项目是一个简单的文件集合,但您使用的是 Git。 Git 不是关于文件; Git 是关于提交。一个提交包含个文件——事实上,每个提交都有一个每个文件的完整快照——但它一个提交,而不是一些文件。然后有些人认为 Git 是关于分支的,但这也不对。分支帮助我们(和 Git)find 提交。但Git 是关于commits 的。所以你需要在这里考虑提交,而不是文件或文件夹。

这并不能解决您的问题,但您需要了解所有相关信息才能解决您的问题。所以请继续阅读。

关于提交的知识

在 Git 中,提交是一个编号实体。每个提交都有一个唯一的编号,Git 将其称为 hash ID,有时也称为 object ID。但是,这些并不是简单的计数:它们不会提交 #1,然后是 #2,#3,等等。相反,每个提交的唯一编号是巨大的——介于 1 和 2160-1 之间——而且显然是随机的,通常用 hexadecimal 表示,例如 cefe983a320c03d7843ac78e73bd513a27806845子>.

这些数字对人类来说毫无用处(因此我们大多不使用它们——如果需要,您偶尔会使用带有剪切和粘贴功能的数字),但它们是 Git 实际查找提交的方式。所以 Git 需要它们。我们稍后会看到如何避免输入它们。

这些编号的实体中的每一个——这些提交——都包含两个东西:

每次提交都有每个文件的完整快照。此快照采用特殊的、只读的、仅限 Git 的、压缩和去重的形式。提交中的文件不能被任何东西使用 Git 本身。

与快照不同,每个提交都有一些元数据,或者关于提交本身的信息:例如,谁做了它(姓名和电子邮件)以及什么时候。

对于 Git 本身来说至关重要的是,每个提交都包含一些较早提交的原始哈希 ID。大多数提交——Git 调用的那些普通提交——持有前一个提交的哈希 ID。

这一切对你来说意味着你只需要告诉 Git latest 提交的哈希 ID。假设我们画了这个,使用大写字母代表实际的提交哈希 ID,这太难看了。让我们用H代替最新的Hash ID:

              H

H 的元数据中,Git 存储了一些较早提交的原始哈希 ID。让我们称之为提交G

          G <-H

通过读取H,Git 将能够获得G 的哈希ID,从而能够读取G。所以我们说H 指向 G

G 本身也是一个提交,因此它具有一些较早提交 F 的哈希 ID,而后者又具有另一个更早提交的哈希 ID:

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

因此,鉴于 最新 提交的哈希 ID,Git 可以轻松找到之前的每个提交,一直追溯到过去。 Git 只是简单地遵循向后的内部箭头,一次一步。

由于HG 都具有完整的快照——在Git 的特殊去重格式中,同样如此——Git 可以轻松地检索GH比较它们,查看更改了哪些文件(如果有)。这让 Git 知道你做了什么,如果你做了 H,并向你展示更改。 Git 没有存储这些更改,它只是在您要求它显示H计算它们,理论上显示自G 以来的更改更有趣而不是显示H 的原始内容。

事实上,只读的不仅仅是快照:any 提交的所有部分都是完全只读的。因此,一旦提交,这些内部箭头就会永远设置为这种方式。

分支和其他名称

不过,要做到这一切,Git 需要知道那个 最新 提交的哈希 ID。这是分支名称进入图片的地方。我们有点懒得画从提交到提交的箭头,然后画成这样:

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

这里 name main 指向提交 HH 指向 G。然而,与提交中的箭头不同,来自分支名称的箭头可以改变:如果我们真的想的话,我们可以让main 指向G。 (这就是git reset 的大部分内容,虽然我不打算在这里介绍。)

我们可以制作更多的分支名称。 Git 对我们分支名称的唯一限制是它们必须指向实际提交。所以我们可以有两个名称​​都指向H,像这样:

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

现在我们有多个名称,我们需要一种方法来记住我们正在使用的名称。现在这两个名字都找到了提交H,所以从某种意义上说,我们使用哪个名字并不重要,但我们即将改变它。因此,为了记住我们使用的名称,我们将在一个分支名称上附加特殊名称HEAD,全大写,如下所示:

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

如果我们现在使用git switch developgit checkout develop,我们会得到:

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

我们仍在使用提交H,我们现在只是通过名称develop 这样做。

除了 branch 名称,Git 还允许我们使用 tag 名称remote-tracking 名称。这些通常也指向提交(尽管标签名称通常通过额外的 Git 对象这样做,以便能够存储注释)。过会儿我会再来讨论这个的。

添加新提交:Git 的索引或暂存区

当我们进行新的提交时,我们:

    使用git checkoutgit switch 选择起始提交; 修改一些文件并对它们运行git add; 运行git commit

您可能想知道为什么我们必须一遍又一遍地运行git add:G​​it 不是第一次得到关于这些文件的备忘录吗?

这里的答案与 Git 的 index 有关。 Git 的这一部分非常核心和重要,Git 迫使您了解它。其他版本控制系统可能有类似索引之类的东西,但是把它隐藏得很好,这样你就不用关心了,但是 Git 在这里非常坚持。 (它实际上有 三个 名称非常重要:它不仅仅是索引或暂存区域:Git 有时称它为 cache。这可能是三个名称中最糟糕的一个,现在大多只出现在 git rm --cached 这样的标志中。)

现在,我们已经知道提交内部的快照是只读的,实际上只有 Git 可以读取这些文件。因此,Git 将不得不将提交中的文件复制 成更可用的形式。这些可用的副本存在于 Git 所称的工作树中:你工作的地方。

这很简单:“签出”某些提交意味着提取其文件。不简单的是,Git 这样做的方式分为两个步骤:

首先,Git 将文件“复制”到其索引中,从其索引中删除先前签出的提交中的文件。

然后 Git 将文件从索引复制到您的工作树。

第一步中的“副本”一词用引号引起来,因为 Git 放入索引中的是 Git 的压缩和去重格式。由于这些文件都来自某个提交,因此它们会自动重复。这意味着它们不占用空间。1但是,它们仍然表现得像副本。

您工作树中的副本是普通文件,采用您计算机的普通格式,解压缩,因此它们确实是 副本。这就是为什么你必须经常运行git add

git add 所做的是读取工作树版本,对其进行压缩和 Git-ify,然后检查它是否重复。如果它重复的,那么存储库中已经存在数据的副本,Git 会重新使用该副本。如果它不是重复的,则压缩数据现在已准备好成为第一个副本,Git 使用该副本。无论哪种方式,Git 现在更新文件的索引条目并且文件已准备好提交。

所以git add 真正的意思是更新我提议的下一次提交,而索引实际上是你提议的下一次 提交。它开始匹配 当前 提交,当您向它发送 git add 文件时,您会更新建议的提交。

这使git commit 的工作更快更轻松。当你运行git commit:

    Git 为新提交收集任何所需的元数据,例如您的姓名和电子邮件地址以及日志消息。 Git 使用 当前提交,通过读取 HEAD 来查看哪个分支名称是当前分支,并读取分支名称以找出提交的哈希 ID,作为 parent 用于新的提交。

    Git 使用步骤 1 中的元数据将索引中的建议提交转换为实际提交。Git 将所有这些作为新的提交对象写入大提交对象-数据库。由于这是一个 new 提交,它会获得一个新的、唯一的、随机的(但不是随机的)哈希 ID:这是 Git 第一次真正找到哈希 ID .由于哈希 ID 取决于所有数据——不仅包括您的姓名和电子邮件地址,还包括您进行提交的确切 - 没有办法提前知道哈希 ID 是什么。

    作为最后一个技巧,git commit将新提交的哈希 ID 写入当前分支名称

结果是我们从:

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

到:

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

commit I 是我们的新提交。

如果我们现在运行 git checkout main,Git 将从它的索引和我们的工作树中删除提交 I 中的所有文件——它们将被安全地永久保存在其中2提交——并从提交H中保存的文件中填写它的索引和我们的工作树。3


1每个索引条目都有一些空间用于文件名、模式、缓存数据和哈希 ID。实际数量有所不同,但在很多情况下平均每个文件不到 100 字节。

2永远,或者只要提交本身存在,就是这样。

3Git 在这里玩了一堆速度和聪明的技巧:它可以很容易地分辨出HI 中的哪些文件不同,并且可以跳过没有不同的任何文件的删除和替换。当在指向同一个提交的分支名称之间切换极端时,这使得git checkoutgit switch 非常快,因为实际上没有任何变化,除了HEAD 的绑定。这也意味着我们可以切换带有未提交工作的分支,因为 Git 不必换出文件。


树枝如何生长;合并

这向我们展示了树枝是如何生长的。我们可以从:

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

从这里,我们创建两个新的分支名称,feature1feature2feature/tallfeature/short 或其他。我将只使用br1br2

...--G--H   <-- main, br1, br2

我们选择 br1 为“开启”,检查它,然后进行两次新的提交:

          I--J   <-- br1 (HEAD)
         /
...--G--H   <-- main, br2

现在我们检查 br2 并进行两个 other 新提交:

          I--J   <-- br1
         /
...--G--H   <-- main
         \
          K--L   <-- br2 (HEAD)

稍后我们将不再费心使用名称main:这并不重要,因为对 Git 来说重要的是提交。请注意,通过H 的提交都在所有三个分支上,而提交I-J 仅在br1 上,而提交K-L 仅在br2 上。

现在让我们查看br1 并运行git merge br2,并快速了解git merge 的工作原理:

          I--J   <-- br1 (HEAD)
         /
...--G--H
         \
          K--L   <-- br2

git merge 命令需要结合工作。为此,它必须找到一个共同的起点: 两个 分支上的最佳提交。 Git 会做通常的从头开始的工作来做到这一点。 (从技术上讲,Git 使用 Lowest Common Ancestor 算法,使用 DAG 的扩展。)在这种情况下,最好的共享提交是显而易见的:它是提交 H

为了合并工作,Git 必须找出变化。为此,Git 将一次在两次提交上运行 git diff。我们开始:

git diff --find-renames <hash-of-H> <hash-of-J>   # what we changed

通过比较 HJ 中的快照,Git 可以找出我们修改了哪些文件,以及我们对这些文件做了什么

接下来,Git 重复这个差异,这次是从 HL

git diff --find-renames <hash-of-H> <hash-of-L>   # what they changed

Git 再次获取已更改文件的列表,以及它们发生了什么。

git merge 现在的工作是合并这些更改。然后 Git 应该将合并的更改应用到 base 版本。

对于我们更改但他们没有更改的文件,这很容易:从我们的提交中获取我们的更改或该文件的版本。换句话说,“来自H 的文件,加上我们的更改,等于来自J 的文件”,因此Git 可以直接从J 获取文件。即使我们从整块布料创建一个新文件,这也适用,例如:如果我们添加了一个文件,而他们没有,Git 可以拿走我们的。

对于没有人更改的文件,Git 可以采用任何版本,因为它们都是相同的。

对于他们更改而我们没有更改的文件,Git 可以获取他们的版本。这也适用于他们删除的文件,我们没有删除,例如:Git 可以删除文件。

只有当我们俩都更改了 same 文件时,它才会变得有点棘手。 Git 现在确实必须将这两组更改结合起来。如果我们都接触了相同的原始行,这可能会产生合并冲突。 (我在这里也跳过了很多挑剔的细节。)

假设一切顺利,Git 只是简单地将我们的所有更改和他们的更改合并,将这些合并更改应用到来自H 的文件,并根据结果进行新的合并提交。这个新的合并提交有一个单一的快照——就像 any 提交一样——但是 Git 没有一个单一的父节点,而是向通常的单一父节点添加了一个 second 父节点。新的合并提交M 指向提交J,就像常规提交一样,但也指向L

          I--J
         /    \
...--G--H      M   <-- br1 (HEAD)
         \    /
          K--L   <-- br2

然后,写出合并提交M,Git 将其哈希 ID 写入当前分支名称 - br1,其中附加了 `HEAD - 像往常一样,我们的合并完成。

你的情况

我有一个feature/mine 分支,我想合并到feature/other,但是在开发过程中的某个地方,对仍在feature/mine 中且不应在feature/other 中的特定文件夹进行了更改。因此,我的 PR 中的差异显示了 src/some-folder-I-dont-want-to-change 中的更改,但这些更改是在提交树的深处进行的。我不能简单地还原一些提交。

此时您谈论的是 GitHub 拉取请求,而不是 Git 合并。 GitHub PR 特定于 GitHub。 Bitbucket 也有拉取请求,我是在您的帖子中添加github 的人,所以也许您实际上是在谈论 Bitbucket 拉取请求。幸运的是,虽然有些细节有所不同,但这里的整体设置是相同的。 (GitLab 称他们为 merge requests,同样,在细节上存在一些差异,但总体思路是相同的。)

要制作 GitHub PR,您:

将您的新提交发送到 GitHub 上的某个存储库:GitHub 分支或共享存储库(哪个并不重要);那么 使用 GitHub 网页单击按钮或gh CLI 脚本来创建拉取请求:这会在 GitHub 上生成测试合并以查看合并是否有效,如果无效,它们会告诉您合并冲突.

要进行该测试合并,GitHub:

找到一个合并基础提交; 做了一个测试合并,大概是有效的; 现在显示您更改了src/some-folder-I-dont-want-to-change 中的一些文件

假设 没有自己更改这些文件,这意味着 GitHub 在这里使用的 merge base 使得 common 开始点出现在其他人更改这些文件之前。

也就是说,假设你有:

         J   <-- feature/mine (HEAD)
        /
       I   <-- feature/third
      /
...--H
      \
       K--L   <-- origin/feature/other

您在其中进行了一次提交 J,该提交更改了不在此 src/some-folder-I-dont-want-to-change 位置的文件。 HJ 之间的比较,但是,包括提交 I

同时,HL 的比较包括对 src/some-folder-I-dont-want-to-change 的任何更改。

因此,您的 PR 显示更改为 src/some-folder-I-dont-want-to-change

如果您愿意,您可以获取提交 L(这是他们最近的提交)并以该形式从中提取 src/some-folder-I-dont-want-to-change 中的所有文件,然后提交结果:

         J--M   <-- feature/mine (HEAD)
        /
       I   <-- feature/third
      /
...--H
      \
       K--L   <-- origin/feature/other

现在,HM 的比较显示 没有 更改为 src/some-folder-I-dont-want-to-change。问题是,你的分支名称feature/mine现在意味着commit M,它——通过一次返回一跳——包括commit I ,这意味着您现在已经取消了对 feature/third 的更改。也就是说,您的提交 M 也可能是一个还原。

如果他们(无论他们是谁)接受您更新的 PR,并建议将 M 合并到 L,结果将包括提交 I如果他们使用 MERGE 按钮。 GitHub 在这里有三个不同的可点击网页按钮:

MERGE:这是一个真正的 Git 合并。您现在已经进行了设置,以便 Git 相信提交 I 已正确合并。这意味着无论是谁做了 提交I 都必须重新做他们的工作。这就是未来的定时炸弹。

REBASE AND MERGE:这使得 GitHub 上的 Git 软件将每个提交 in 复制到 PR 到一个新的和假定改进的提交。这将产生类似的效果,尽管它改变了提交I 的人必须处理事情的方式。

SQUASH AND MERGE:这根本不会创建合并。这可以防止这种特殊的定时炸弹。它们用一个提交替换所有“你的”提交——包括你从别人那里继承的提交I。效果是,无论谁提交了I,都不必重新提交,因为最后没有人会看到你的提交JM(你将不得不丢弃你的分支,因为任何南瓜)。

了解其运作方式和原因是这里的关键。如果您要设置未来的定时炸弹,那么无论谁做出了您实际上正在还原的提交,都需要知道这一点。如果您使用的是 squash-and-merge 方法(它还有其他缺点),则具有完全消除这种情况的有益副作用。

旁注:origin/feature/other

名称origin/feature/other 是一个远程跟踪名称。在查找提交方面,这与分支名称具有相同的效果。 branch 名称和 remote-tracking 名称之间的主要区别在于后者是您的 Git 为记住其他 Git 存储库的 branch 而创建的名称姓名。您不能切换到远程跟踪名称(git checkout 产生分离的 HEAD,git switch 产生错误)。

当您运行 git fetch 以从其他 Git 存储库获取提交时,您的 Git 会读取其他 Git 的分支名称。为了保留您的分支名称​​你的——避免用他们的覆盖你的——你的 Git 软件重命名他们的分支名称。然后,您的 Git 会创建或更新这些远程跟踪名称。远程跟踪名称,例如 origin/mainorigin/develop 或其他名称,其前面有 remote origin:因此术语 remote-tracking name 。 (Git 文档称这些 远程跟踪分支名称,但我发现这里的词 branch 具有负值:删除它会产生一个改进的术语。)

变基

假设这个图表,或类似的:

         J--M--N   <-- feature/mine (HEAD)
        /
       I   <-- feature/third
      /
...--H
      \
       K--L   <-- origin/feature/other

抓住了真正的问题,你还有另一种选择。您可以将现有的提交 J-M-N 替换为从 H 开始的新的和改进的提交,而不是从 I 开始。 git rebase 命令可以帮助解决这个问题。

为此,您需要运行:

git switch feature/mine
git rebase --onto <hash-of-H> feature/third

如果名称main 指向提交H,您可以使用:

git switch feature/mine
git rebase --onto main feature/third

您也可以在此处使用提交 I 的原始哈希 ID 而不是名称 feature/third。总体思路是我们想告诉git rebase 两件事:

    将副本放在提交H 之后:这就是为什么我们需要提交H 的哈希,或者名称main找到正确的提交的任何东西都可以在这里工作。

    使用当前分支复制提交,但不要复制提交I 或早于I 的任何内容。任何定位提交 I 的内容都足够了:名称 feature/third 可以做到这一点,但提交 I 本身的原始哈希 ID 也是如此。

rebase 命令将从当前提交开始,这里是 N,然后向后工作,直到它到达要从复制中排除的提交:在这种情况下,提交 I 和更早的提交。这些是复制的候选对象。4 然后它将要复制的提交按正确的顺序排列,将它们的实际哈希 ID 保存在工作列表中。 (交互式 rebase,git rebase -i,允许您编辑工作列表,但您可能不希望在这里。)

一旦列表准备好,git rebase 使用 Git 的 分离 HEAD 模式来复制每个要复制的提交,一次一个。与其详细说明它是如何工作的——尽管我会在这里说它使用 git cherry-pick 或等效的——我只会画出如果一切顺利的话你得到的最终结果:

         J--M--N   [abandoned]
        /
       I   <-- feature/third
      /
...--H--J'-M'-N'   <-- feature/mine (HEAD)
      \
       K--L   <-- origin/feature/other

这个最终结果有 您的提交的副本,但省略了 not-copied I 提交。因此,您不再对 未更改的文件进行任何更改:您的三个 - 或无论多少 - 提交都从与 origin/feature/other 相同的 基本提交 开始。因此,如果您现在使用 git push --force 更新您的 GitHub PR,对这些其他文件的明显更改将会消失。

无论某人使用何种合并策略,合并都不会包含对这些其他文件的任何更改,并且不会设置定时炸弹。所以这种方法往往更胜一筹。这就是 git rebase 的用途:设置新的和改进的提交,做你想做的事。


4此候选人名单通常由以下人员进一步筛选:

删除任何合并提交; 从列表中删除 patch IDs 与上游匹配的提交;和 如果启用了--fork-point,则使用分叉点技巧。

在这种情况下,这些都不应该有太大的影响。但是,如果您在列表中有合并提交,事情就会变得复杂得多。

【讨论】:

感谢您的来信。很有意思。也许回答这个特定问题的信息有点太多了;这使得在那里找到要点变得有些困难。我会考虑你的建议!

以上是关于Git:如何在我的项目中设置一个与远程分支相同的文件夹?的主要内容,如果未能解决你的问题,请参考以下文章

Git 合并远程分支

我可以在 Azure DevOps 中设置默认安全和/或分支策略吗?

如何让新分支显示在 Eclipse Git 远程跟踪中?

如何在 git-gui 中设置所需的语言?

Nginx:如何在服务器块中设置环境变量

你如何停止在 Git 中跟踪远程分支?