git - 将分阶段更改提交到另一个分支并合并

git - Commit staged changes to another branch and merge

我经常发现,在处理大型功能分支时,我会更改真正属于它们自己分支的部分代码库。我知道我可以使用 git add -p 来暂存我想要的更改,提交它们,隐藏我不想要的更改,从 master 创建一个新分支,挑选我之前所做的提交,切换回原始分支,重置,合并到功能分支,然后弹出我的更改,但这似乎是本应该更容易做的事情的大量工作。应该有一种方法可以做到这一点而不影响我的工作目录,对吧?

这是我正在尝试做的事情的绘图。

我喜欢这样的命令

$ git commit --onto master --as new

这将在 master 之外创建一个 new 分支,在那里提交更改,然后将其合并到我的 HEAD 分支中,所有这些都不会触及我的工作目录。有这样的命令吗?

没有任何这样的命令,但您可以构建自己的命令。这会有点(或可能很多)棘手。对于一般情况,您需要一个临时的 work-tree,在这种情况下,您将不得不停止并让用户修复合并冲突。但是,如果你只想声明这个特定的 fully-general 案例超出范围,并且只处理 merge-conflict-free 案例,你可以避免单独的 work-tree,我们将在下面看到。

警告:这是 (a) 长且 (b) 全部未经测试

(我把它写成一种学术练习,说明 Git 是如何工作的,以及它的各个部分是如何组合在一起写成 new Git 命令,主要是 shell 脚本。)

请记住,当您 运行 git commit 时,Git 会根据您在 index 中的任何内容构建一个新的提交。索引是不可见的 cache-like 数据结构,它占据了 HEAD(或当前)提交和 work-tree.

之间的 space

在您的绘图中,标记为 staged 的虚线圆圈是您 git commit 转换索引后的提交成一棵树,然后用新的提交 object 包裹那棵树 object。您的 work-tree 未在此过程中使用(一个例外与某人是否具有 运行 git commit --onlygit commit --include 相关,它们构建新的临时索引文件,然后在内部使用git add 从 work-tree 复制到新的临时索引中,但让我们在这里避免这个特定的老鼠洞)。

进行普通提交的过程分解

通常,您不需要知道所有这些:git commit 命令会搞定这一切。事实上,您可以使用该命令,除了您不需要 想要 普通提交之外,您想要 合并提交 。所以我们需要手工做一些事情,and/or走一些更长更迂回的路线。让我们从观察 如何 git commit 开始新的提交,如果我们现在 运行 git commit

请注意,每次提交都包含 所有 文件的完整快照。标记为 staged 的虚线圆圈将成为真正的提交,这也将通过以下过程自动更新 dev。为清楚起见,省略了所有错误检查。我在这里假设日志消息在 shell 变量中可用,尽管使用 -F <em>file</em> 也可以获取日志来自文件的消息。在查看了此处的四个命令后,我们将对此进行一些分解,但另请参阅每个命令的单独手册页:

current_branch=$(git symbolic-ref HEAD)  # will fail if HEAD is detached
tree=$(git write-tree)                   # will fail if, e.g., index is unmerged
commit=$(git commit-tree -p HEAD -m "$message" $tree)         # can fail
git update-ref -m "commit: $subject" $current_branch $commit  # can fail

git symbolic-ref命令从HEAD读取当前b运行ch的name。大多数 Git 操作从 HEAD 获取当前 commithash ID,但我们需要名称——在此案例,refs/heads/dev,因为你在你的 dev b运行ch — 最后一步。

write-tree 将索引打包为树 object。从本质上讲,这会永久冻结索引中文件的内容,以它们现在的形式。生成的顶级树 object 适合新提交。

commit-tree 创建了使用此冻结树的提交 object。它需要知道新提交的 parent 是什么;这就是 HEAD 通过 -p HEAD 提供的任何哈希 ID。它需要日志消息;这就是 -m(或 -F)参数的用途。并且,它需要进入提交的 object 的哈希 ID;这就是 $tree 的目的。

(提交由 git commit-tree 刚刚编写的提交 object 本身,加上 git write-tree 编写的树 object,加上所有的 blob objects,它们已经在索引中,连同 link 它们一起所需的任何 sub-trees,git write-tree 在写入顶级树时写入。)

使 提交,但当前 b运行ch,refs/heads/dev 仍然命名 old 当前提交 - 在我们刚刚进行此新提交之前 当前的提交。我们现在必须解决这个问题,这样,虽然 HEAD 本身仍然只引用 refs/heads/dev,但 refs/heads/dev 本身引用 new 提交。这会导致新提交成为当前提交!执行此操作的 Git 命令是我们四个命令 git update-ref 中的最后一个。 -m 参数提供了进入我们的 reflog 的消息。常规 git commit 命令作为此日志消息使用字符串 commit: 后跟完整日志消息的主题(第一行,或多或少),因此我们将其放入 shell 变量 $subject 并在这里使用它。它还需要知道新的哈希 ID 以填充到引用名称中,这当然是我们刚刚进行的新提交,$commit,来自 git commit-tree.

这就是 git commit 现在会为您做的事情:它会成为一个普通的 single-parent 通讯t,在 b运行ch dev 上,更新 b运行ch 名称 dev 以指向新创建的提交。新的提交将通过它的树 object,永远冻结索引中所有文件的内容。不幸的是,那不是你想要的。你想要的是让 Git 进行一个新的提交,其类型不是普通的 single-parent 提交,而是一个其类型是 merge 提交: 具有两个 parent 的提交。此合并提交的 first parent 应该像往常一样是当前 (HEAD) 提交,但是 second parent 这个提交应该是一个 new 提交...好吧,这就是它变得棘手的地方。

为了进行新的合并提交,您必须首先进行新的 other 提交

为了得到你想要的东西——图的 右边 的图——我们需要先做标记为 new 的新提交。

为了进行此提交,我们必须将所有文件的外观构建到索引中的快照。注意我这里说的是an索引,而不是the索引。我们开始处理一些并发症! (这也是 git commit --onlygit commit --include 做的事情。)

因为Git是围绕快照构建的,而不是change-sets,我们必须先打开current 索引变成了change-set。也就是说,我们必须将当前提交与索引进行比较,以查看我们在此处更改了哪些文件以及我们对它们做了什么:

git diff-index --cached -p HEAD

此处的输出(大部分)与 git diff --cached 的输出相同,但它使用 管道命令 而不是 瓷器 (user-configurable) git diff 前端。这可确保输出采用良好、稳定的 easy-to-digest 格式,可供其他程序使用,包括其他 Git 命令。

请注意,这种差异将 HEAD 中的树与索引 / staging-area 表示的树进行比较。它完全忽略了 work-tree 中的树。这就是我们想要的,因为这就是 git commit 将提交的内容:索引中的任何内容。我们现在想要索引中的任何内容,与 HEAD 中的冻结树相比,以补丁的形式。

这个补丁现在适合应用到 b运行ch master 的提交中的树——标记为 master 的实心圆在你的照片中。

在普通 Git 用法中,我们将此补丁 应用到 这棵树的方法是提取树——与 master——进入work-tree。但这是您 想要的。此外,除非在应用此补丁时存在无法解决的冲突,否则我们根本不想 设为临时 work-tree。

不过,让我们先探讨一下。

使用添加的 work-tree

我们可以在这里使用 git worktree add,自 Git 2.5 起可用。由于一个相当讨厌的小错误,除非你有一个漂亮的 up-to-date Git,否则最好避免将它们保存超过两周,但我们的计划可能是使用它不超过几 ,这样就可以了。该错误已在 Git 2.15.

中修复

添加的 work-tree 带有自己的 HEAD 和自己的索引。它还提供了我们进行完整 git apply -3 和允许合并冲突所需的所有空间。所以我们可以:

path=$(mktemp -d)
git worktree add -b new $path master

创建一个名为 new 的新 b运行ch,指向与 master 相同的提交,将添加的 work-tree 存储在 $path 中,这是一个新的临时目录。

在其私有 work-tree 中创建了这个新的 b运行ch,我们现在只需要应用我们刚刚提取的补丁:

# this bit of clumsiness is due to the subshell problem
# (there are multiple workarounds, this one is simple)
status_file=$(mktemp)
echo fail > $status_file

git diff-index --cached -p --full-index HEAD | 
    (cd $path
    if git apply -3; then
        git commit -m "$message" && echo success > $status_file
    fi
    )

read status < $status_file; rm $status_file

case $status in
success)
    new_commit=$(cd $path && git rev-parse HEAD)
    git worktree remove $path
    ... finish up the job (see below) ...
    ;;
fail)
    echo "oops, sorry, things went wrong"
    echo "the mess is left in $path"
    echo "you will need to finish the merge and finish the job"
    ;;
esac

git apply命令应用补丁。 -3 标志指示它在必要时使用 three-way 合并。我还将 --full-index 添加到 git diff 操作中,以便我们在补丁中获得完整的哈希 ID,这使 git apply 的工作更容易,尽管从技术上讲,在现代 Git(确保索引行足够——对于旧版本的 Git,大型存储库需要 --full-index)。

注意我们可以在这里使用git cherry-pick,而不是git diff... | git apply。从技术上讲,这实际上更好,因为它可以处理 diff-and-apply 技术无法处理的一些文件重命名情况。但是我们正在考虑在不添加 work-tree 的情况下执行此操作,当我们这样做时,我们将无法使用 git cherry-pick.

使用临时索引而不是添加 work-tree

我们现在可以做的是开始指示 Git 使用临时索引,使用特殊环境变量 GIT_INDEX_FILE。这里有一些特殊之处:无论 $GIT_INDEX_FILE 中的路径是什么,Git 都要求文件 不存在 具有 a 的形式有效索引。所以我们可以这样做:

tf=$(mktemp)
rm $tf

这将创建一个具有唯一名称的临时文件,然后将其删除。现在 $tf 适合用作 GIT_INDEX_FILE,因为它命名了一个 不存在的文件

我们也可以把临时文件放在.git目录下:

tf=$(TMPDIR=$(git rev-parse --git-dir) mktemp)

但我认为这里没有必要。

或者,我们可以借用方法git stash使用:

TMPindex=${GIT_INDEX_FILE-"$(git rev-parse --git-path index)"}.stash.$$

但是用我们自己的脚本的名称替换 stash,不管它是什么——我在下面使用 $tf 而不是 TMPindex。请注意 git rev-parse --git-path index 本身是 Git 2.13 中的新内容,因此如果您的 Git 较旧,请不要使用此方法。

现在我们有了临时索引,我们可以指示各种 Git 命令使用该 而不是 常规索引。

要构建我们的新提交,我们必须:

  1. master 的顶端提取树到那个索引
  2. 将我们的补丁 应用到 该索引,而不触及任何 work-tree。这可能会失败!
  3. 使用该索引创建新提交,就像我们在说明正常提交时使用常规索引创建新提交一样。完成所有这些后,我们将准备好创建 merge 提交。

忽略失败案例,我们现在需要 [edit:根据评论,我在下面删除了 --full-index-3--cached 模式无法进行三向合并]:

GIT_INDEX_FILE=$tf git read-tree refs/heads/master
git diff-index --cached -p HEAD |
    GIT_INDEX_FILE=$tf git apply --cached
tree=(GIT_INDEX_FILE=$tf git write-tree)
new_commit=$(git commit-tree -p refs/heads/master -m "$message_for_new" $tree)
git update-ref -m "$subect_for_new" refs/heads/new $new_commit

read-tree 命令从给定的提交中提取树——在这种情况下,master 的尖端——到索引文件中,我们将其重定向到我们的临时索引。

diff-index命令就是我们已经看到的。它使用真实索引。

这次 apply 命令添加了 --cached,因此它仅将 的更改应用到索引,做一个 three-way 如果需要合并。我们为此使用临时索引。 (我们失去了进行适当的三向合并的能力,因此失败的可能性比以前更多!)

write-tree 命令将临时索引写入一棵树,现在准备进入提交,commit-tree 命令将这棵树变成提交。我们之前看到了这一切——这次不同的是,新提交的 parent 是 b运行ch master (refs/heads/master) 的尖端,当然我们有不同的提交信息。 update-ref 创建或更新名为 new 的 b运行ch——相当粗鲁地 丢失 之前任何名为 [=60 的 b运行ch =],所以小心点可能是明智的,或者根本不用 b运行ch name (即删除 git update-ref完全步骤)。

进行合并提交

现在我们有了新的提交,其哈希 ID 在变量 $new_commit 中,我们准备好返回到我们原来的 four-command 序列,该序列在 dev 然后更新 dev。要将这个新提交创建为 merge commit,而不是普通提交,我们只需要给它两个 parents.

因此,再次忽略所有错误处理,命令序列为:

current_branch=$(git symbolic-ref HEAD)
tree=$(git write-tree)
commit=$(git commit-tree -p HEAD -p $new_commit -m "$message" $tree)
git update-ref -m "commit: $subject" $current_branch $commit

综合起来

这是一个完全未经测试、有些危险的 no-error-handling-provided 脚本:

#! /bin/sh

current_branch=$(git symbolic-ref HEAD)
other_branch=refs/heads/master

message_for_new="magic new commit from script

I am a commit made by applying a diff.  I was made automatically by a script.
This is a terrible commit message, indicating that the script needs improvement."

message="new merge from script

I am a merge commit made by pretend-merging a magic commit made on ${other_branch#refs/heads/},
but actually using the staged files on ${current_branch#refs/heads/}.
This is a terrible commit message, indicating that the script needs improvement."

subject=$(printf '%s\n' "$message" | sed -n -e 1p)

# create a temporary index, and be sure to clean it up on exit
tf=$(mktemp); rm $tf; trap "rm -f $tf" 0 1 2 3 15

# create new ordinary commit via patch from current index
# this commit has $other_branch as its (single) parent
GIT_INDEX_FILE=$tf git read-tree $other_branch
git diff-index --cached -p HEAD |
    GIT_INDEX_FILE=$tf git apply --cached
tree=$(GIT_INDEX_FILE=$tf git write-tree)
new_commit=$(git commit-tree -p $other_branch -m "$message_for_new" $tree)

# create new merge commit on current branch, using this index and
# the commit just created above
tree=$(git write-tree)
commit=$(git commit-tree -p HEAD -p $new_commit -m "$message" $tree)
git update-ref -m "commit: $subject" $current_branch $commit