18

In Git I can use an interactive rebase to re-write history, this is great because in my feature branch I made a ton of commits with partially working code as I explored different refactors and ways of getting it done.

I'd like to squash a lot of the commits together before rebasing or merging the branch onto master.

Some made up commits in order from first (top) to bottom (last)

1. Initial commit on feature branch "Automatic coffee maker UI"
2. Add hot chocolate as product
3. Add tea as product. Products are now generic
4. Create in memory data store for adapter tests
5. Cry because I can't get entity framework to create a composite key. Integration tests broken.
6. Implemented composite key!!
7. All tests green and feature done!

Lets say I want to keep commits 3, 4 and 7.

Using rebase I want to "squash" commits

  • 1 and 2 go into 3.
  • 4 stays
  • 5 and 6 go into 7

Ideally in the interactive rebase I would do

1. squash
2. squash
3. pick (contains the work of 1 & 2)
4. pick 
5. squash
6. squash
7. pick (contains the work of 5 & 6)

But that is backwards because squash merges a commit with its previous commit. I can't figure out how to make it squash forward.

Am I being difficult, and should I accept that won't work (I'd rather get it working), or is there a way to accomplish this?

I'm invoking this command with

git checkout My-feature-branch
git rebase master -i

Then I'm editing the list of commits that come up, and trying to finish it by saving the file and editing the editor, which typically works for me.

torek
  • 448,244
  • 59
  • 642
  • 775
Josh R
  • 1,970
  • 3
  • 27
  • 45
  • I'm not entirely sure what you're experiencing. `squash` would do exactly what you're looking for; meld 1 and 2 into 3, and 5 and 6 into 7. What would be left behind would be (ostensibly) commits, 3, 4, and 7. Are you running into an issue with these commands? Note that you can try these on your local repository just fine, but **only if you have a remote repository you can reset back to**. – Makoto May 26 '17 at 22:17
  • I get a message "error: cannot 'squash' without a previous commit", that threw me for a loop and makes me think #1 is trying to squash to #0, which doesn't exist and not #2 – Josh R May 26 '17 at 22:31
  • How are you invoking this? Show us that command. – Makoto May 26 '17 at 22:33
  • 1
    @Makoto you are wrong, it is the other way around, just as Josh said already in his question. ;-) Also, due to the reflog being there by default, you can always easily undo what you did with a rebase without any remote repository being present. ;-) – Vampire May 26 '17 at 23:29
  • Great question. I needed exactly this thing, as reordering commits give you conficts. @Vampire's answer is a great solution that does not cause any additional work. – Filip Kubicz Dec 22 '21 at 11:13

6 Answers6

7

You either need to also reorder the commits so the to-be-kept commit comes before the to-be-squashed commits if this is feasible. (For an alternative, see the update at the end of the answer)

If this is not feasible, because you then would get conflicts you don't want to resolve, just make it

1. pick
2. squash
3. squash
4. pick 
5. pick
6. squash
7. squash

When the squashes are done, you can edit the commit message to contain the message you like the final commits to have. Easy as pie. :-)

You might even be able to do

1. pick
2. fixup
3. squash
4. pick 
5. pick
6. fixup
7. squash

Then I think there should only once the commit message editor being fired up, as with fixup the previous commit message is simply taken without launching the editor.

On squash when the commit message editor fires, you also get both commit messages, the one from the to-be-squashed-into and the to-be-squashed commit, so you then can simply delete the commit message you don't want to keep.


Update:

To use the author date and commit message of 4. and 7. and thus be more what OP originally wanted, you can use this setting:

1. edit
2. fixup
3. fixup
4. pick 
5. edit
6. fixup
7. fixup

Then on the first break you use:

git commit --amend -C master~4 # or any other way to reference commit number 3

Then you continue the rebase and on the second break you use:

git commit --amend -C master # or any other way to reference commit number 7

Then you continue the rebase and are done.

or to automate it:

1. pick
   exec git commit --amend -C master~4 # or any other way to reference commit number 3
2. fixup
3. fixup
4. pick 
5. pick
   exec git commit --amend -C master # or any other way to reference commit number 7
6. fixup
7. fixup
Vampire
  • 35,631
  • 4
  • 76
  • 102
7

It is indeed possible to squash a commit into the following commit during interactive rebase, and fully preserve the identity of the second of those commits (including author, date etc.).

The method is somewhat involved however, so native support by git rebase -i would still be appreciated nevertheless.

I'll demonstrate with just three commits aaaaaaa A, bbbbbbb B and ccccccc C, where we want to fold A into B and preserve B's identity: (The method easily generalises to more commits)

  • git rebase -i aaaaaaa^
  • Modify the edit script to stop after commit B and exit the editor:
    pick aaaaaaa A
    edit bbbbbbb B
    pick ccccccc C
    
  • When rebase has stopped after B, revert the latest commit twice, then continue:
    git revert HEAD
    git revert HEAD
    git rebase --continue
    
    Because reverting the revert restores the previous state, all following commits will apply cleanly. The sequence of B and Revert "B" together has no effect, but the first of those commits carries the full identity of commit B (in fact, at this stage it still is commit B).
    B and Revert "B" could be squashed together to form a no-op commit carrying the identity of commit B forward with a separate rebase -i --keep-empty aaaaaaa^. This is very useful and recommended to prevent bogus merge conflicts, especially in more complex cases. However we'll skip that step here and just keep the commits together.
    The important commit now is Revert "Revert "B"" which will carry all the changes originally made by B.
  • Now rebase -i aaaaaaa^ again to move A past both B and Revert "B", while squashing B into both Revert "B" and A:
    pick   bbbbbbb B
    squash b111111 Revert "B"
    squash a222222 A
    squash b333333 Revert "Revert "B""
    pick   c444444 C
    
    Because the sequence of B and Revert "B" has no effect, you can move any commits past it while creating only trivially resolvable merge conflicts.
  • When rebase stops with a merge conflict, use git mergetool with a tool that can automatically resolve trivial conflicts, and let the tools do just that.
  • Rebase will stop again to let you edit the commit message of the squashed commit. Edit to your taste.
  • git rebase --continue and you are done.
tera
  • 7,080
  • 1
  • 21
  • 32
  • 1
    This should be the answer. It solves the question properly. – Diogo May 22 '21 at 04:59
  • I agree, though my view may be biased. Not sure if we can convince @JoshR to bother after four years, but I'm glad you found my answer helpful. – tera May 23 '21 at 10:07
4

Vampire's answer is right, but I want to offer a different perspective. I think this is where you are getting yourself more wound up than necessary: you start with:

Lets say I want to keep commits 3, 4 and 7.

but then add:

  • 1 and 2 go into 3.
  • 4 stays
  • 5 and 6 go into 7

But this means you want to keep (the contents of) all of 1, 2, 3, 4, 5, 6, and 7 ... just not as separate commits. The interactive rebase squash does not mean "throw away", nor even "throw away commit message", it just means "combine". It might be better if the verb were "combine" or "meld" or "mix-in" or some such. So the sequence (pick, squash, squash) means: keep all three, while applying them in that order, then make one big commit out of them.

As already noted, once Git goes to make one big commit out of them, Git gives you another chance to edit the three combined commit messages into one big commit message.

When the rebase is done, you haven't kept any of the original commits. Instead, you have made new commits. It doesn't matter precisely how your new first commit was assembled from parts, only what the final source was, and what you put in the one big commit message.

torek
  • 448,244
  • 59
  • 642
  • 775
  • 3
    Call me picky, but while totally correct, this does not answer the OP question, but only tells him that he used the wrong wording, doesn't it? If so, it should be a comment to the question, not an answer. – Vampire May 29 '17 at 16:52
  • @Vampire: yes, probably should be a comment. The comment limits (space and formatting) make it not fit really well though. And, it's not necessarily just *wording:* the idea that the commit changes ID, and hence isn't the original commit at all, is important, since other branches may retain the original commit by its original ID. – torek May 29 '17 at 18:07
  • I'd like to add this helped me re-think my end goal. Both answers were valuable and I now think that dropping my "intermediate" commits would have been okay as well. – Josh R May 30 '17 at 17:50
  • 1
    @JoshR if you would have dropped the commits, you would also have dropped the changes you made within them. – Vampire May 31 '17 at 09:31
  • @Vampire that would have been very bad. Thanks for the heads up. I was under the impression that dropping the commits would only get rid of the messages. – Josh R Jun 01 '17 at 21:30
  • 1
    Right: you don't want to *drop* them, you want to sum them up into a new commit 1a that equals 1 + 2 + 3 in terms of source changes (assembled by three `git cherry-pick -n` ops). – torek Jun 01 '17 at 21:58
  • 1
    Or squashes or fixups in an interactive rebase, so exactly what he wanted to do. He just confused the order into which the stanzas work. Using `cherry-pick` for this could work, but would be pretty cumbersome if you can simply do an interactive rebase. – Vampire Jun 02 '17 at 01:07
  • @Vampire: yes, what I mean is that under the hood, the interactive rebase is doing the cherry-pick operations. – torek Jun 02 '17 at 01:31
2

My go-to way to do this is a simply a reset plus a commit -C.

git rebase -i origin/main # or some other commit

Which brings up your editor, which I make look like this:

...
pick aafaa Merge me forward
edit bbfbb Into me
...

Then when the rebase stops at bbfbb, do

git reset --soft HEAD^
git commit --amend -C HEAD@{1} # could put an explicit commit here
git rebase --continue

It can also be put into a single rebase todo-list & easily simplified to just an amend, since the commit hash you want is in the line above.

...
pick aafaa Squash me forward
fixup bbfbb Into me
exec git commit --amend -C bbfbb
...
golvok
  • 1,015
  • 12
  • 25
  • While [tera's answer](https://stackoverflow.com/a/65963300/18751) is ingenious, this solution is much simpler, while also answering the OP's question 'properly'. – mloughran Jan 12 '23 at 15:55
0

To improve upon golvok's answer, if you want to "forward-squash" multiple commits, you can do the following:

  1. (optional) If you modified and subsequently untracked a file in the unpushed commits, make sure to stash or clean untracked files first.

  2. Start the rebase:

    git rebase -i origin/destination_branch
    
  3. Mark with fixup all commits in-between the first and last you want to squash, and place the below exec command after the target commit:

    pick a52ff17 Feature A added
    pick b69a7d9 Feature B WIP
    fixup da37b5a Feature B WIP
    fixup 009557e Feature B WIP
    pick 4857cdd Feature B added
    exec git reset --soft HEAD^ && git commit --amend -C HEAD@{1}
    pick 15ffacb Feature C added
    

There you go, easy-peasy.

AgentRev
  • 749
  • 1
  • 8
  • 20
-1

What you want is:

1. pick
2. squash
3. fixup
4. pick
5. squash
6. squash
7. fixup

Then when the new combined commits come up in the editor delete the lines for the picked commits and leave the fixups. The resulting feature-patches should have the commit message from the last commit in the series that way.

davolfman
  • 266
  • 1
  • 9