TL;DR
使用临时标签来标记具有所需文件副本的提交。然后,使用git rebase -i 并在每个pick 之后插入x 命令以运行一个简短的脚本。您可以选择在此脚本中添加的确切内容,但这(未经测试)可能是您想要的:
#! /bin/sh
git checkout temp-tag -- path
git diff-index --quiet HEAD || git commit --amend --no-edit
这一切都完成后,移除临时标签(和脚本;它并不难写,标签和路径是硬编码的)。
长
要理解这个答案,首先要记住这个事实:在 Git 中,文件实际上并不在分支中。文件确实在commits中。
提交包含在分支中——或者换句话说,通过使用分支名称找到,然后通过 Git 存储在每个提交中的链接从提交到提交,向后工作。因此,您可以从分支名称转到提交,然后再转到文件。但“提交”步骤至关重要,因为每个提交都有每个文件的完整快照。
接下来,让我们看看git rebase 做了什么以及它是如何做到的。请记住,Git 是关于 commits 的,每个提交都有一个唯一的哈希 ID。任何现有提交的任何部分都不能更改。因此,由于 rebase 从字面上 can't 更改任何 现有 提交,它必须通过 复制 旧的(和糟糕的,或至少在某些方面不够)提交新的和改进的提交。这些新的和改进的提交在某些方面与旧提交相同,但在某些方面有所不同。
通过其唯一的哈希 ID 找到的每个提交都包含两个部分:
-
有一个提交的主要数据:这个提交附带的源代码快照。这些不是更改。如果稍后签出某个特定提交,则快照具有每个文件应显示的完全相同。
-
除了数据之外,每个提交都有一些元数据,或者关于提交本身的信息:谁做了它(姓名和电子邮件地址),何时(日期和时间戳)等等。
元数据将“谁提交”分为两部分:作者是最初提交者的姓名、电子邮件和时间戳,提交者 是提交此变体的人的姓名、电子邮件和时间戳。所以当我们像这样复制一个旧的提交时,我们保留了原作者,但设置了一个新的提交者。如果您正在复制自己的提交,这意味着姓名和电子邮件并没有真正改变——旧的有你作为两者,而新的有你作为两者——但是 committer 时间戳 改变。
不过,最重要的是,每个提交都会记录其上一次或 父 提交的哈希 ID。变基的重点通常是接受这样的一串提交:
I--J--K <-- feature
/
...--G--H--L <-- mainline
并制作提交 I、J 和 K 的新版本和改进版本,以便新提交来自 L 而不是来自 H:
I--J--K <-- feature
/
...--G--H--L <-- mainline
\
I'-J'-K' <-- new-and-improved-feature
其中commit I' 是commit I 的“副本”(某种意义上),J' 是J 的副本,K' 是K 的副本。
不用过多担心复制过程的机制——尽管我会在这里提到它使用git cherry-pick——让我们做最后一个观察,那就是我们(和 Git)的方式 ) find commits 就是使用 branch name 来查找链中的 last 提交。当提交H 是mainline 的最后一次 提交时,我们找到了它,因为我们有:
...--G--H <-- mainline
name mainline 持有提交 H 的哈希 ID。所以git checkout mainline 将提取提交H 供我们使用或处理/处理。但是后来我们或某人做了一个新的提交,添加到到mainline,我们称之为提交L,所以我们有:
...--G--H--L <-- mainline
name mainline 现在拥有提交 L 的哈希 ID。 git checkout mainline 命令将提取提交 L 供我们使用。为了find commit H,我们必须让 Git 打开 commit L 并读取其元数据。此元数据包含早期提交 H 的原始哈希 ID。
这对我们意味着,一旦我们完成了这个:
I--J--K <-- feature
/
...--G--H--L <-- mainline
\
I'-J'-K' <-- new-and-improved-feature
我们可以将名称 feature 关闭提交 K 并将其粘贴到提交 K' 上,如下所示:
I--J--K ???
/
...--G--H--L <-- mainline
\
I'-J'-K' <-- feature
现在,当我们尝试查看分支 feature 上的提交时,我们将使用 name feature 启动 Git 来定位提交 K'。提交K' 指向之前的提交J',后者又指向I',,后者又指向L。一旦我们移动分支名称,我们的 rebase 将完成,并丢弃我们在 构建 I'-J'-K' 序列时可能使用的任何时髦的特殊名称。
(练习:提交I-J-K 会发生什么?这有关系吗?我们怎么知道它们是否仍在存储库中?)
考虑到前后关系,让我们看看git rebase 是如何工作的
我在上面简单地提到了git rebase 使用git cherry-pick 来复制每个提交。反过来,cherry-pick 命令的工作原理是……嗯,从技术上讲,它是一个成熟的三向合并,但首先,通过查看当我们只比较 两个 提交。
让我们从这张“之前”的照片开始:
I--J--K <-- feature
/
...--G--H--L <-- mainline
我们需要让 Git check out 提交 L,这是我们想要新提交的地方。如果我们以正常方式执行此操作,我们将创建一个新的分支名称,例如 tmp,使用:
git checkout -b tmp <hash-of-L>
(或与 Git 2.23 或更高版本中的 git switch 命令相同)。 Git实际上为此使用了它所谓的分离的HEAD模式,特殊名称HEAD直接指向一个提交:
git checkout <hash-of-L>
或:
git switch --detach <hash-of-L>
产生这个:
I--J--K <-- feature
/
...--G--H--L <-- HEAD, mainline
现在 Git 运行 git cherry-pick <em>hash-of-I</em>。 Git 在整个设置过程中保存了提交I、J 和K 的哈希ID。如果您在此处使用git rebase --interactive,您将看到列出这些哈希ID 的pick 命令。1pick 代表一个樱桃挑选命令。
cherry-pick 本身最终会将提交H 中保存的快照与提交I 中保存的快照进行比较。 这两个快照之间的区别实际上是一组可以应用于快照的指令。将该组指令应用于H 中的快照会生成I 中的快照。但是如果我们将这些指令应用于L 中的快照呢?
如果我们这样做——假设它可以工作并且没有合并冲突2——并根据结果进行新的提交,我们将得到提交I'。我们将让 Git 按原样保存原始作者信息和原始提交消息,并生成一组新的提交者信息并使用我们通过应用差异获得的 snapshot。结果是:
I--J--K <-- feature
/
...--G--H--L <-- mainline
\
I' <-- HEAD
Git 现在继续执行git cherry-pick <em>hash-of-J</em>,通过比较I-vs-J 并将其应用于I' 来复制提交J:
I--J--K <-- feature
/
...--G--H--L <-- mainline
\
I'-J' <-- HEAD
最后——因为只有三个提交——我们最后一次挑选提交K,比较J-vs-K(如果你是J-vs-J'对cherry-pick的合并方面感兴趣)来构建提交K',这给我们留下了这个:
I--J--K <-- feature
/
...--G--H--L <-- mainline
\
I'-J'-K' <-- HEAD
剩下的唯一任务是将名称feature 指向当前提交 K' 以获取:
I--J--K ???
/
...--G--H--L <-- mainline
\
I'-J'-K' <-- feature (HEAD)
这完成了变基过程。
1您可以编辑的 git rebase 的说明表具有缩写的哈希 ID。我一直不太清楚为什么:Git 必须将它们扩展回来才能在内部使用它们。也许 Git 人员只是认为当有 7 或 12 个随机字符而不是 40 个字符时,它们看起来不那么令人生畏。对于git describe 输出,这可能会出现在某人的电子邮件或其他东西中,当然——但在这里,它们只是说明在临时页面上,如果您编辑它们,您可以在编辑器中使用“移动行”指令。
2合并冲突(如果有)源于比较 H 中的快照与 L 中的快照。至少第一个樱桃采摘就是这种情况。随后的两个樱桃选择使用提交 I 和 J 作为合并基础,其中 --ours 提交是在 previous 步骤中构建的提交。这就是事情变得有点棘手的地方。
你想要什么
我相信您想要的是,在每次挑选之后,您希望 new 中的某个特定文件(复制)与某个特定早期提交中的某个特定文件完全匹配。
假设现有提交 K 具有所需的文件版本。我们要做的——避免依赖 Git 不移动名称 feature,并让你选择任何提交——是创建一个临时的轻量级标签来标识这个提交:
git tag temp-tag <hash-of-K-or-whatever>
注意:如果没有一个固定版本的文件应该进入每个复制的提交,您将需要一个不同的策略来为checkout 定位源提交,但是其余的可以继续工作。
接下来,我们将使用git rebase -i。这会将精选集变成可编辑的说明书。使用我们的编辑器,在每个 pick 命令之后,我们使用exec 或x 命令添加一行:
pick <hash>
x /tmp/script
(假设我们的小脚本已放入 /tmp/script 并可执行)。
Git 将一直执行 cherry-pick 命令,直至完成,这涉及进行新的提交(在我们的示例中为 I'、J' 或 K')。然后它会因为这个x 行而运行脚本。脚本:
-
从特定提交中提取特定文件:使用temp-tag,我们从所需提交中获取所需文件,将其放入 Git 的索引和工作树中。 (索引副本是最重要的,但更新工作树也很好,如果没有别的,为了理智。)
-
测试结果是否值得替换提示提交 (git commit --amend)。这是我们的git diff-index --quiet HEAD。如果索引仍然与当前提交匹配,则无需更改。否则,我们将运行git commit --amend,它将当前提交推开并创建一个新提交。使用--no-edit,我们告诉git commit 简单地重复使用现有的提交消息。
注意:在这种情况下,即使没有更改,git commit --amend --no-edit 实际上是安全,但这是浪费精力。对于这个脚本和任务,这可能并不真正相关,但最好不要执行很多不必要的工作。
因此,这将确保在 rebase 期间每个替换提交本身都被替换,并且将单个文件换出为我们想要的文件进行“更正”替换。这样一来,当 Git 开始从旧分支中提取分支名称并将其放在替换提交的末尾时,每个替换提交都是实际所需的新提交和改进提交。
除了清理(删除轻量级的temp-tag 标记并删除脚本)之外,无需进行任何其他操作。