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