2

I am looking for a way to squash commits with git rebase, but non-interactively. My question is, can we use git merge-base to find the common ancestor to "merge down to" and then squash all commits that are not shared?

E.g.:

git init
# make changes
git add . && git commit -am "first"
git checkout -b dev
# make changes to dev 
# *(in the meantime, someone else makes changes to remote master)*
git add . && git commit -am "second"
SHARED=$(git merge-base dev master)
git rebase ${SHARED}

above, because no changes to master happened in the meantime, I am not sure if this script would do anything. But what it definitely will not do, is squash commits. Is there anyway I can somehow squash all commits made on the dev branch, that are not in master?

Maybe the best thing to do would be to rebase, and then follow it with:

git reset --soft ${SHARED}

The reset --soft command should squash commits

To be clear, here is what I am looking for:

  1. clean linear history given by git rebase
  2. squash commits from feature branch without unnecessary interactivity

git rebase -i uses interactivity, which I want to avoid

Alexander Mills
  • 90,741
  • 139
  • 482
  • 817

2 Answers2

3

Doing this with git merge --squash

I answered this below with a method using git rebase first, and then a subsequent git reset --soft ... && git commit sequence. See that part of this answer for details. However, you can do all the "rebase-and-squash" work in a single Git command, because git merge --squash does what you want: it performs the action of merging (merge as a verb), without making a merge (merge as a noun: a merge commit). (You still need a few extra setup and finish commands—there is a total of five commands, in the end, unless you want to try it as a script.2)

Let's start with a sample input graph:

...--o--*---------Z   <-- master
         \
          A--B--C     <-- dev

Now, your end goal—see the other section for details—looks like this:

...--o--*---------Z      <-- master
         \         \
          \         D    <-- dev
           \
            A--B--C      [abandoned]

where commit D is, in effect, what you get if you turn the A--B--C sequence into a changeset to apply to commit *, then apply that changeset, using three-way-merge, to commit Z with * as its merge base. This, of course, is just what a regular git merge would do.

The problem is that a regular merge would make a merge commit, giving:

...--o--*---------Z
         \         \
          \         D
           \       /
            A--B--C

We do want commit D, but we want D to be an ordinary, single-parent, commit. This is where git merge --squash shines. This is precisely what git merge --squash does: it merges (verb) but then makes an ordinary commit—or rather, makes us make the commit (we have to run our own git commit command).

We also want something else, though: we want D to be the tip commit of branch dev, with branch master not moved. I carefully filed off the branch names from the merge graph above, because if we did a regular merge the regular way, we'd do it while being on branch master, and commit D would then wind up on branch master. That's not what we want.

Now, there is a very simple rule git commit uses when making a new commit: the new commit goes on the current branch. So, assuming the merge is already done but there's no commit yet, how can we get Git to make the new commit D on the correct branch, i.e., on dev? Well, we can't.1 Instead, we should make this squash-merge commit D on a temporary branch.

We start by checking out commit Z, the tip of master, as a new temporary branch:

git checkout -b temp-for-squash master

Now we run git merge --squash dev, to set up our index with the merge result that will become commit D:

git merge --squash dev

Now we commit the result, because --squash implies --no-commit:

git commit

The result at this point looks like this:

...--o--*---------Z      <-- master
         \         \
          \         D    <-- temp-for-squash (HEAD)
           \
            A--B--C      <-- dev

Now all we have to do is make the label dev point to temp-for-squash, which is where we are now. We could do this with git checkout dev; git reset --hard temp-for-squash, but we can do it in one step:

git branch -f dev HEAD

I have deliberately used the name HEAD here, because—well, look at where we used the name temp-for-squash. We used it just once, in the git checkout command. And, now that we've moved dev, we don't need the temp-for-squash name any more. So let's not use it at all—let's change the git checkout above to:

git checkout --detach master

to get a "detached HEAD". Everything else works as before, because a detached HEAD acts as an anonymous branch. The new commit we make, when we run git commit after git merge --squash, makes commit D on this unnamed branch, updating HEAD. Then we use git branch -f dev to move dev.

Last, we probably want to run git checkout dev, so that we are on the dev branch (but we can git checkout any branch name you like now). Hence, the final command sequence is:

git checkout --detach master
git merge --squash dev
git commit
git branch -f dev HEAD
git checkout dev

The advantage to doing this, over doing the two-part "first git rebase, then git reset" sequence is that when you do rebase, and it copies a chain of commits, you may get merge conflicts on each copied commit. If so, you will probably get a merge conflict on the squash merge too—but with the squash merge, you will have one big conflict to resolve once, instead of many small conflicts to resolve one commit at a time.

This is also the disadvantage to doing this. It may be easier to resolve many small conflicts, one commit at a time. Or it may be easier to resolve one big conflict once. It's your repository, and these are your commits; you get to decide.


1There are actually at least two ways to do it, but all of them involve various kinds of cheating: using commands that are meant for scripts. The simplest is to use git symbolic-ref to rewrite HEAD directly. I'll mention it here but leave working out the details, i.e., why this works, as an exercise:

git checkout --detach master
git merge --squash dev
git symbolic-ref HEAD refs/heads/dev
git commit

There is a second method in the next footnote.

2Here's an untested script that takes just one parameter, which is the branch on which to rebase-and-squash. To use the script you must be on the branch you intend to rebase-and-squash, i.e., dev. This does a bit of cheating and the previous value of the dev head will be just ORIG_HEAD rather than dev@{1}, because we update dev a few times, and if the merge needs assistance, we'll force the user (i.e., you) to do the git commit.

Note: I use git-sh-setup for various functions, such as die and require_clean_work_tree. This means you must invoke this script with the git front end, i.e., place it somewhere in your $PATH, named git-rebase-and-squash, then run git rebase-and-squash.

#! /bin/sh

self="rebase-and-squash"

. git-sh-setup

[ $# = 1 ] || die "usage: $self <branch-or-commit>"

require_clean_work_tree $self

source=$(git rev-parse HEAD) || exit 1
target=$(peel_commitish "$1") || exit 1

# Everything looks OK.  Set ORIG_HEAD, then move current branch
# to target commit.  We'll rely on ORIG_HEAD to protect the chain.
set_reflog_action $self
git update-ref ORIG_HEAD $source
gut reset --hard $target
if git merge --squash $current_branch; then
    # merge succeeded without assistance: do the commit
    git commit
else
    status=$?
    echo "merge failed - finish the merge and run 'git commit',"
    echo "or use 'git reset --hard ORIG_HEAD' to abort."
    exit $status
fi

Doing this with git rebase and git reset --soft

Pockets' answer is correct (and upvoted) for the case you originally described. You may need an explanation, though, especially since the arguments to the various commands may need a bit of tweaking in other setups, such as the more general case you seem to be aiming for.

I am looking for a way to squash commits with git rebase ...

All git rebase does is copy commits, then move the current branch label to point to the last-copied commit. There's no reason you have to use git rebase itself to do these steps, although for some cases, it will be easier.

but non-interactively. My question is, can we use git merge-base to find the common ancestor to "merge down to" and then squash all commits that are not shared?

Yes.

Your necessary steps, which are those performed by git rebase, are:

  • find the merge base or otherwise identify the commit(s) to copy;
  • do whatever copying you wish; and
  • move the branch label accordingly.

To understand the actions (and hence commands) we need, start by drawing the graph (or the interesting part of it):

...--o--*           <-- master
         \
          A--B--C   <-- dev

Here, the name master points to the merge base commit. (I'm using a slight variant of the git init; ... sequence you showed above, here, with an existing repository with at least one commit before the merge base commit.) Let's make this graph a bit more interesting by making a new commit on master, though:

...--o--*--Z        <-- master
         \
          A--B--C   <-- dev

You may want your graph, in the end, to look like this:

...--o--*--Z        <-- master
         \
          D         <-- dev

where D is the result of combining A-B-C, as git rebase -i would do if you changed all but the first pick to squash while rebasing onto the merge base commit *.

Note that the saved snapshot for this new commit D, with its new commit message (whatever that is), is the same as the saved snapshot for commit C. That is, combining A-B-C is essentially the same as forgetting A and B graph-wise, while keeping the code they introduced, so that the tree for D matches the tree for C.

Now, if there is no commit Z on master, you can do this with Pockets' answer. The reason is that if you are sitting on commit C, with the index and work-tree matching that for C, and you do:

git reset --soft master

this will retain the index and work-tree as is, but change the graph to read:

...--o--*           <-- dev (HEAD), master
         \
          A--B--C   [abandoned]

When you then run git commit, you will make a new commit on dev, which will be commit D:

...--o--*           <-- master
         \
          D         <-- dev

and you are now done. But if there is a new commit Z, things change.

If you want:

...--o--*--Z        <-- master
         \
          D      <-- dev

then the trick is to git reset -soft to the merge base commit *, and otherwise do the same thing. But if you want:

...--o--*--Z        <-- master
            \
             D      <-- dev

then you need considerably more work.

In this case, the simplest solution without writing any code is probably to git rebase (non-interactively) first to copy A-B-C to A'-B'-C' that descend from Z, giving:

...--o--*--Z        <-- master
            \
             A'-B'-C'  <-- dev

Now you can git reset --soft master && git commit as before, to make commit D from the index that git rebase made when it made commit C'.

Community
  • 1
  • 1
torek
  • 448,244
  • 59
  • 642
  • 775
  • well, Pocket was saying to use git reset --soft, which will help with squash. I was saying I still want to rebase for the normal reasons. I will have to read your answer a couple more times to fully grok it, but AFAICT what you saying is to *git rebase master* first and then *git reset --soft master* to do the squash afterward, with no interactivity necessarily required. ? – Alexander Mills Feb 20 '17 at 01:44
  • @torek the solution I suggested will work for squashing a feature branch even if `master` and `dev` have diverged. – Pockets Feb 20 '17 at 01:45
  • agreed that if master hasn't changed, there is no need for rebase. in my code snippet above, I don't show that master has changed but I think I should add that assumption in the original question for clarity. – Alexander Mills Feb 20 '17 at 01:46
  • @Pockets yes, but I need to rebase too, that is the goal. – Alexander Mills Feb 20 '17 at 01:46
  • also, just to verify, if we do call git rebase master, but dev and master haven't diverged, there shouldn't be a problem, it's a essentially a no-op, is that correct? – Alexander Mills Feb 20 '17 at 01:49
  • 1
    @AlexanderMills: yes, a rebase that observes that no commits need to be copied/moved, is just a no-op. – torek Feb 20 '17 at 06:47
  • @Pockets: if you `git checkout dev && git reset --soft master && git commit`, in the situation where `dev`'s merge base is commit `*` that comes before commit `Z`, you will "lose `Z`'s changes" as the tree for new commit `D` will match that for original commit `C`, while the parent will be `Z`. Compare with the the post-rebase `C'` above, where `A-B-C` were turned into patches that were applied atop `D`, so that they start from `D`'s tree as their base. – torek Feb 20 '17 at 06:50
  • (Let me add a side note as well: you can apply `--no-ff` to *force* `git rebase` to copy commits where the rebase would normally be a no-op. This is meant for re-copying a branch that was merged, and then the merge was reverted. See https://github.com/git/git/blob/master/Documentation/howto/revert-a-faulty-merge.txt for details. That doesn't matter here, it's just a useful thing to keep in mind if you ever get into that situation.) – torek Feb 20 '17 at 06:54
  • @torek I just facepalmed at myself - I completely agree with you now and have amended my original answer to reflect that. It so happens that I have a use case for the answer I suggested and at some point I managed to confuse in my head squashing a patch series with just wholesale dropping another Git tree on top of a branch. – Pockets Feb 20 '17 at 20:51
2

The following will plop the dev tree into a new commit on master (note: this is not equivalent to a rebase, as @torek kindly points out below):

git checkout dev         && \
git reset --soft master  && \
git commit

Note that you should not have uncommitted pages or untracked files when doing this.

Here's how it works:

  1. checkout dev will update HEAD, your index (staging area), and working tree to match the state of the repository at dev,
  2. reset --soft master will not affect your index or working tree (which means that all the files in your staging area will reflect exactly the state of the repository at dev), but will point HEAD at master, and
  3. commit will commit your index (which corresponds to dev) and walk master forward.

The &&s ensure that the script does not continue if anything fails.

Pockets
  • 1,186
  • 7
  • 12
  • cool thanks; I think you could run the rebase after reset --soft? – Alexander Mills Feb 19 '17 at 21:58
  • Why do you want to `rebase`? This is equivalent to `rebase -i`, `2,$s/pick/squash/ | wq`. Also, if you do try to `rebase` after `reset --soft master` all you're going to be doing is trying to rebase `master` onto `master`, which is instantly going to fail if `dev` has diverged in any way because you have a dirty index. – Pockets Feb 19 '17 at 22:00
  • Well rebase makes for a cleaner/simpler history. Maybe I could rebase before doing reset --soft instead of after? – Alexander Mills Feb 19 '17 at 22:36
  • There is literally no difference between what I've suggested here and `git rebase`, unless you're thinking about what's happening to the reflog. – Pockets Feb 19 '17 at 23:19
  • git rebase does not squash commits unless you use the -i flag, that's the difference right? reset --soft will squash commits with the need for interactivity. I want to rebase to get the clean linear history, and I want to squash commits and I don't want to use the -i flag if I don't have to. The only way to do this is to use reset --soft in conjunction with git rebase. Right? The question is: how to use them in conjunction? – Alexander Mills Feb 19 '17 at 23:28
  • It remains incredibly unclear what you want to do. You can either `rebase` to replay all divergent commits starting from the merge-base or `reset --soft && commit` (`rebase -i` if interactivity is OK) to squash the commits. – Pockets Feb 20 '17 at 00:06
  • Np, I understand it's confusing - I updated the question with the two things I am looking for - so far I have had luck with *git rebase master* followed by *git reset --soft master*. This seems to do what I want, if that makes sense. – Alexander Mills Feb 20 '17 at 00:20
  • I think what you might be confused about is that in my example master hasn't changed, but actually I am assuming master *has* changed while I was making changed to dev branch. I will update the question. thanks. – Alexander Mills Feb 20 '17 at 01:50