简短的回答是“不,没有更好的方法”——但您或许可以尝试使用git worktree add。 (这会让你遇到一个不同的问题,但它可能毕竟不是问题。)
问题是只有一个 index,1 Git 称之为 the 索引,或者有时是暂存区 或 缓存。同时,任何提交,一旦做出,就永远无法改变。甚至git commit --amend 也没有更改提交(我们稍后会谈到)。
1这并不完全正确。特别是,如果您使用git worktree add,您将获得一个索引每个工作树。 Git 还允许各种命令使用临时索引文件;像git stash 之类的东西,甚至是更复杂的git commit,使用它是因为git write-tree 总是使用索引作为它们的输入,但可以指向一个临时索引。不过,实际上使用临时索引太棘手了。
当您使用git add -p 或一些更高级的 GUI 以交互方式选择要添加到索引中的特定更改(差异块或单独的行或其他)时,您正在索引中创建一个在其他地方没有出现的文件。
想象一个只有一个文件README的非常简单的存储库。您克隆存储库并位于master。情况如下:
HEAD index work-tree
------ ------ ------
README README README
README 的所有三个副本都是相同的(尽管秘密地,Git 的 HEAD 和索引版本是压缩的,并且实际上共享底层磁盘存储,因此 README 的磁盘上只有两个映像,压缩的 Gitty 一个和未压缩的纯文本)。
现在你启动你最喜欢的编辑器并修改README。让我们称之为README;1 只是为了有一个可怕的语法2 来识别“文件的不同版本”。 :-) 现在你有了这个:
HEAD index work-tree
------ ------ ------
README README README;1
但是,您进行了重大更改,因此您决定使用git add -p 或其他方式以交互方式添加一些更改。一旦你这样做了,你就会得到 this:
HEAD index work-tree
------ ------ ------
README README;2 README;1
也就是说,从字面上看,现在您正在同时处理该文件的三个不同版本:您无法更改的已提交版本;您更改的工作树;和索引一,您创建为 HEAD 和工作树版本的科学怪人混合体。
由于只有一个索引,因此只有一个位置可用于创建此文件的中间版本。所以你必须创建它,然后提交它。提交会生成一个 new 提交,它会获得一个新的唯一哈希 ID,然后使该新提交成为 HEAD 提交,因此您现在拥有:
HEAD index work-tree
------ ------ ------
README;2 README;2 README;1
这释放了索引以创建文件的另一个新变体:README 的第二个版本在HEAD 提交中安全保存,完全不可更改(并且大部分是永久的)。
2这实际上是 VMS 中版本化文件的语法。
In a comment,mkrieger1 链接到a question where the answers suggest using the fixup and autosquash features of git rebase, including using git commit --fixup to record a commit for autosquashing。这些,比如git commit --amend,利用了 Git 分支名称的一个非常有用的属性。
Git 中的分支名称指向恰好一个提交。分支中包含的一组提交是通过从一个提交开始确定的在。每个提交都存储在它的大丑提交哈希 ID 下:分支名称包含提示提交的哈希 ID,每个提交包含其父提交的哈希 ID:
... <--E <--F <--G <-- master
我们说master“指向”提示提交G,它又指向F,依此类推。
由于不能永远更改任何提交,因此内部箭头总是指向后,并且不需要绘制,这很方便,因为在 stackoverflow 上很难用 ASCII 做好。 :-) 所以我把它画成:
...--E--F--G <-- master
(我将箭头保留在分支名称前面,因为分支名称会移动。)
现在,这意味着如果我们进行 new 提交,其父级不是正常的“当前提交哈希 ID”,而是当前的 父级提交,然后让当前分支名称指向我们刚刚提交的新提交,我们似乎已经替换了当前提交:
H <-- master
/
...-E--F--G
我们实际上没有更改任何提交,但是当我们运行 git log 时,它看起来像我们所做的,因为没有任何东西指向 G,Git 没有不显示它。 Git 首先向我们显示新的提交H,然后返回提交F,然后返回E,以此类推。
这就是您使用git commit --amend 得到的结果:新提交只是具有作为其父级的当前(嗯,现在是前当前)提交具有的父级。
git rebase 命令将这一点提升到一个新的水平:我们可以复制许多提交,而不是一个提交,在我们进行的过程中进行一些细微的更改。通过交互式 rebase,我们让 Git 在每个要复制的提交上使用 git cherry-pick。副本可以在您喜欢的任何提交之后进行,但对于诸如 autosquash 之类的事情,您通常会就地执行副本:
...--E--F--G--H--I--J <-- branch
其中J 是H 的修正。现在你运行git rebase -i --autosquash <hash of G>,Git 会生成命令:
pick <hash-of-H>
pick <hash-of-I>
pick <hash-of-J>
如果完全直接运行,将导致:
H'-I'-J' <-- branch
/
...--E--F--G--H--I--J [abandoned]
但 autosquash 功能并没有运行它们,而是注意到 J 本身有一个前缀:fixup! <subject> 作为其一行提交主题。其中的<subject> 部分与H 的提交主题匹配,因此autosquash 代码将指令更改为:
pick <hash-of-H>
fixup <hash-of-J>
pick <hash-of-I>
执行这些指令会给出:
HJ--I' <-- branch
/
...--E--F--G--H--I--J [abandoned]
其中HJ 是自动压缩的H+J,使用H 的提交消息。
现在,再一次,这里的问题是只有一个索引,而您正在在那个索引中构建中间图像。
如果您使用git worktree add,您可以制作任意数量的工作树。每个都有自己的索引,当然也有自己独立的工作树。但是 Git 施加了一个非常强的约束:每个工作树必须在不同的分支上。
毕竟这可能不是问题。请记住,在 Git 中,分支非常便宜:创建一个 new 分支只需要一个磁盘块,包含一个 41 字节的文件。 (未来的实现可能会改变这些细节,但分支仍然会非常便宜。)
让我们回到这张图:
...--E--F--G--H--I--J <-- branch (HEAD)
我们现在可以创建一个新分支,Git 所做的只是写一个文件,也指向提交J。这就是为什么我们将(HEAD) 添加到绘图中,以便我们知道我们的工作树正在使用哪个分支:
...--E--F--G--H--I--J <-- branch (HEAD), br2
我们现在可以随意添加、复制、变基或其他任何内容。新的提交完全是只读的并且大部分是永久的,它们是安全的,并且总是与旧的提交分开。新名称 br2 可以安全地保留原始提交,无论我们如何处理它们。或者,我们可以将 HEAD 切换为 br2 并让旧名称 branch 保持原始提交的安全:
...--E--F--G--H--I--J <-- branch, br2 (HEAD)
现在让我们像以前一样对H-I-J 做一些事情:
...--E--F--G--H--I--J <-- branch
\
HJ--I' <-- br2 (HEAD)
如果你创建一个新的工作树,你可以让它有一个新的分支。新工作树共享所有旧提交和所有旧分支。
Git 禁止您拥有两个使用 same 分支的工作树,因为这样它们都将指向同一个提交,但又有两个索引文件和两个工作树。当您在其中一个中进行新提交时,它将使用该索引中的索引,并将 shared 分支更改为指向新提交。结果是这两个中的另一个仍然具有旧的(现在陈旧的)索引和旧的(现在陈旧的)工作树。 Git 作者认为这太令人困惑,并简单地取缔了它。 因为分支是如此便宜,这实际上是相当合理的:只需为每个新工作树创建一个新分支。
您在这里的优势在于您不仅拥有多个索引文件,而且还拥有多个工作树。您可以停止尝试使用每个文件的额外版本来玩索引文件技巧 (git add -p):只需让工作树文件看起来像您想要的那样,然后 test 它,然后提交它.所有这些都在可能是临时分支的临时工作树中,如果它们不能很好地工作的话。如果他们确实工作得很好,请使用最好的作为最终结果。一旦您满意,只需删除 (rm -rf) 所有较小的工作树和 (git branch -D) “毕竟没有解决”临时分支。