Let's say I start with a history like this:
A---B---C
\
\-D---E
Then, I merge the two branches, resolving some conflicts in the process:
A---B---C---F
\ /
\-D---E-/
But afterwards, I want to rewrite history to look something like this:
A---B---C---EF
\ /
\-D-----/
In other words, I want to move the changes from branch commit E
into merge commit F
. As if I had made the changes in E
while manually resolving conflicts from merging D
into C
.
(My practical reason is that E
was merely a merge preparation commit, to make anticipated conflicts a little easier to resolve. It doesn't have much value on its own, and I don't want it polluting the history. I don't want to squash E into its predecessor, because its changes are logically separate.)
I tried:
git rebase -i --rebase-merges HEAD~10
But that gives me:
...
pick 1069500 ...
merge -C 44c0a69 ...
# s, squash <commit> = use commit, but meld into previous commit
# ...
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
# . create a merge commit using the original merge commit's
# . message (or the oneline, if no original merge commit was
# . specified). Use -c <commit> to reword the commit message.
So it looks like a commit can be a merge commit, or git rebase -i
can squash it into its predecessor, but not both. And git rebase
would make me reapply my manual conflict resolutions, anyway, which kinda stinks.
Is there another approach?
Use git commit-tree
to make a new merge commit with F
's snapshot and your chosen parents, then force your branch name to point there:
newcommit=$(git commit-tree -p HEAD^ -p HEAD^2^ HEAD^{tree} -F /tmp/msg)
where /tmp/msg
contains the commit message you'd like to use. Then use git reset
to move the current branch / commit to $newcommit
, after checking that this commit looks the way you'd like (git log --graph --oneline $newcommit
for instance).
(This assumes a Unix-like shell, with shell variables set with var=value
, $(...)
to run a command, and so on.)
Commits do not store changes. They store snapshots.
This is true for merge commits as well: their snapshots are snapshots. They hold full and complete copies of every file in each file's merged form.
So suppose you have:
A--B--C--F
\ /
D----E
(this is your original drawing, I just pushed the commits around a bit to a style I like better) and would like to have:
A--B--C--G
\ /
D----'
(I just used a whole new letter for merge G
here, but this is the same as your EF
). The snapshot for merge commit G
must exactly match the existing snapshot for merge commit F
. The first parent of merge commit G
must be commit C
—the same as the first parent of existing commit F
—and the second parent must be commit D
, skipping over commit E
, but most importantly, the snapshot for G
should be exactly the same as the snapshot you already made with merge commit F
.
Now, you can easily produce this graph:
___
/ \
A--B--C--F G
\ / /
D----E /
\_____/
from the graph you already have, by making new commit G
. The git commit-tree
command does exactly that.
You must supply three things to git commit-tree
:
A set of parent hash IDs, or names that resolve to hash IDs. You need the IDs of commits C
and D
, which you can spell using HEAD^1
and HEAD^2^1
respectively. Each 1
can be omitted. These are the -p
arguments.
The tree hash ID for the new commit. Since HEAD
is commit F
and its tree is the one you want, HEAD^{tree}
specifies that.
A commit log message, with -m
or -F
or from stdin.
The commit-tree program writes out the new commit object and prints its hash ID, which we'd like to capture in a human-readable form using a shell variable.
Once this is done we just need to update the current branch name. Since this branch is currently checked out, we will use git reset
:
git reset $newcommit
We could git reset --soft
to avoid changing the current index, or git reset --hard
to change the index and work-tree, but if the current index and work-tree match the snapshot in existing merge commit F
, the snapshot in merge G
is exactly the same anyway so there's no real need for either flag. The default --mixed
reset will reset the index, leaving the work-tree undisturbed. (If you've carefully staged stuff, though, you might want --soft
.)
Commit F
will continue to exist, but with no name that finds it, you won't see it—and eventually, once the reflog that keep it around expire, Git's garbage collector will toss out commits F
and E
.