4

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?

2 Answers 2

3

TL;DR

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.)

Long

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.

Sign up to request clarification or add additional context in comments.

3 Comments

Just to be sure, F^{tree} or HEAD^{tree} refers to the snapshot alone (just the files), while F refers to the whole commit (files, message, author, etc.), such that we should have ${newcommit}^{tree} == F^{tree} after the git commit-tree command. Is this correct? You are a great explainer, thank you.
@leogama: yes, just so: the ^{<type>} suffix tells the revision parsing code that after it locates the hash ID for whatever comes before the caret, it should then do whatever it takes to find a suitable hash ID for the given type. Some types don't convert—for instance, if the hash on the left of ^{tree} is a blob hash, the ^{tree} produces an error. But a commit always has exactly one tree so F^{tree} works every time when F is a commit.
Note that some (mostly "porcelain") Git commands do the equivalent magic for you, e.g., git diff, given a commit hash specifier, converts the commit hash to a tree hash to do the diff-ing. Other (mostly "plumbing") Git commands don't, so that you need some syntax or code to get the right kind of object.
2

I would do it like this:

git checkout $( git commit-tree -p C -p D -m "some merge" F^{tree} ) 

You will end up in detached HEAD on a revision just like what you are requesting (check with git log or gitk, check the files).... if you like it:

git branch-f some-branch git checkout some-branch 

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.