Cover photo by JESHOOTS.COM on Unsplash
I've been working with Git for a few years now and admittedly, a clean commit history wasn't always that important to me. As we know from Back to the Future, rewriting history could have some bad consequences. I can only assume that's why it's such a scary topic. In this post I'd like to share the basic commands, which will help you to keep your Git history clean and take away your trauma from Back to the Future ;-)
First things first
All commands in this post will generate new commit hashes and therefore diverge your branch from origin. That means you have to do a force push to overwrite the history on remote with git push --force
or git push -f
This in turn means: NEVER change the git history on a shared branch.
There's a safer option that will reject even a force push if the remote branch contains newer commits than your local branch:
git push --force-with-lease
Scenario 1: Add something to your last commit
Everyone has been in a situation like this. You add your changes to the staging area, commit it and wait for a green build. Unfortunately your build fails. Of course, you forgot to add file x. What's coming next? Add file x to the staging area, commit with a message like "stupid me forgot to add file x" aaaaaaaaand STOP! Let me introduce you to our first command:
git commit --amend
This command adds your changes from the staging area to the last commit and gives you the opportunity to change your last commit message. You can also do that with one single command:
git commit --amend -m "new message"
If you don't want to change your last commit message, you can just add the --no-edit parameter:
git commit --amend --no-edit
So no more "stupid me forgot" commits!
Git interactive rebase
For the next few scenarios we're going to use interactive git rebase. This tool helps us to modify changes that are further back in history. You simply start an interactive rebase with the following command:
git rebase -i <base>
Where <base>
means how far back you'd like to rewrite your history.
For instance, rewrite last three commits:
git rebase -i HEAD~3
Another example, rewrite history to a specific commit:
git rebase -i 63a2356
The command now shows you the commits and options in your configured editor:
pick bcfd87e add sql scripts for database
pick 9fb0b9c prevent users from changing their email
pick e0b46b9 cleanup web config
# Rebase dfef724..e0b46b9 onto dfef724 (3 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup <commit> = like "squash", but discard this commit's log message
# x, exec <command> = run command (the rest of the line) using shell
# b, break = stop here (continue rebase later with 'git rebase --continue')
# d, drop <commit> = remove commit
# l, label <label> = label current HEAD with a name
# t, reset <label> = reset HEAD to a label
# 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.
#
# These lines can be re-ordered; they are executed from top to bottom.
#
# If you remove a line here THAT COMMIT WILL BE LOST.
#
# However, if you remove everything, the rebase will be aborted.
#
# Note that empty commits are commented out
Let's jump into the first rebase scenario.
Scenario 2: Add something to an arbitrary commit in your history
Like in scenario 1 you forgot to add something to your commit. But instead of adding it to your last commit, you'd like to amend that change to a commit further away in history. For such a case, interactive rebase provides us the edit option.
We start with the interactive rebase command (see above) and set the edit command on the commit we'd like to change:
pick bcfd87e add sql scripts for database
edit 9fb0b9c prevent users from changing their email
pick e0b46b9 cleanup web config
Interactive rebase now stops at this commit and you're able to add your changes:
Stopped at 9fb0b9c... prevent users from changing their email
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue
After you're done you simply add the changes to the staging area and commit them with the amend command:
git commit --amend
Almost done! Now we have to continue the interactive rebase:
git rebase --continue
Scenario 3: Merge commits
I try to commit as often as possible. That means my git history in a feature branch usually looks something like this:
9edf77a more review findings
67b5e01 review findings
940778d enable users to change their name
dc6b0db enable users to change their name
dfdd77d wip
That may be useful during feature development, but it's not for the overall git repository history. Therefore I'd like to merge all commits into one. There are two options to do this: squash and fixup. Both commands meld a commit into the previous one. The only difference is that squash lets you provide an option to write a new commit message while fixup discards the message.
So let's squash some commits. We start the interactive rebase like we did before and set the appropriate options:
pick dfdd77d wip
squash dc6b0db enable users to change their name
squash 940778d enable users to change their name
squash 67b5e01 review findings
squash 9edf77a more review findings
# Rebase 63a2356..9edf77a onto 63a2356 (5 commands)
Git now provides an overview with all messages:
# This is a combination of 5 commits.
# This is the 1st commit message:
wip
# This is the commit message #2:
enable users to change their name
# This is the commit message #3:
enable users to change their name
# This is the commit message #4:
review findings
# This is the commit message #5:
more review findings
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
We remove all lines and write just the message we'd like to have in the end:
enable users to change their name
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
And voilà, we have squashed our commits into one:
cb88cf6 enable users to change their name
We can do the same with the fixup command. In the scenario below I'm happy with the commit message where I'd like to meld my commits into:
pick dc6b0db enable users to change their name
fixup dfdd77d wip
fixup 940778d enable users to change their name
fixup 67b5e01 review findings
fixup 9edf77a more review findings
# Rebase 63a2356..9edf77a onto 63a2356 (5 commands)
The result is the same like the squash example above:
b8e76d1 enable users to change their name
Scenario 4: Split commit
Now and then we tend to commit two or more different topics into one:
7080968 add sql scripts for database
172db2b prevent user from changing their email and cleanup web config
In this case it would be a lot cleaner to separate the cleanup of our web config, wouldn't it? At this point we can take advantage from the edit command again. Let's start the interactive rebase session and do that:
pick 7080968 add sql scripts for database
edit 172db2b prevent user from changing their email and cleanup web config
# Rebase dfef724..172db2b onto dfef724 (2 commands)
As you expect, Git stops at our commit. Now we can take back our changes into the working area:
git reset HEAD~
The rest is straightforward, just create two commits for our changes:
git add [files]
git commit -m "prevent user from changing their email"
git add [files]
git commit -m "cleanup web config"
Don't forget to continue the rebase session:
git rebase --continue
Let's see the result:
9fb0b9c prevent user from changing their email
dfef724 cleanup web config
7080968 add sql scripts for database
Neat!
Scenario 5: Reorder commits
What if you'd like to merge two or more commits and they're not in order?
09f43c9 validate user inputs
62490ed import user data
c531f57 validate user inputs
You simply reorder the commits inside the rebase editor. After that you can use the fixup or squash option to merge them.
pick 62490ed import user data
pick c531f57 validate user inputs
fixup 09f43c9 validate user inputs
# Rebase 6c70ff2..09f43c9 onto 6c70ff2 (3 commands)
Scenario 6: Change commit message
Not satisfied with a specific commit message? With help of the reword command you can always change it:
reword c531f57 validate user inputs
pick 62490ed import user data
# Rebase 6c70ff2..09f43c9 onto 6c70ff2 (3 commands)
Git will stop at this commit and you have the option to change the message:
validate user inputs
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
Scenario 7: Drop commit
Need to revert a specific commit completely? For such a scenario we can use the drop command:
pick bcfd87e add sql scripts for database
drop 9fb0b9c prevent users from changing their email
pick e0b46b9 cleanup web config
# Rebase dfef724..e0b46b9 onto dfef724 (3 commands)
Here, at the latest, here you can see why it's important to keep your Git history clean. Imagine you've put different features into one commit or split up a feature into multiple (pointless) commits. A revert would be much more complicated.
When something's going wrong
Don't worry about breaking something when rewriting your Git history.
First, you can always abort an interactive rebase session with git rebase --abort
This command will stop the rebase session and revert all changes you already made.
Second, even if you've completed a rebase and messed it up somehow, you can revert it. Git (luckily) keeps track of all your commands that you execute. You open this log with git reflog
e0b46b9 (HEAD -> feature/my2, feature/my3) HEAD@{34}: rebase -i (finish): returning to refs/heads/feature/my2
e0b46b9 (HEAD -> feature/my2, feature/my3) HEAD@{35}: commit: cleanup web config
9fb0b9c HEAD@{36}: commit: prevent users from changing their email
bcfd87e HEAD@{37}: reset: moving to HEAD~
3892bc7 HEAD@{38}: rebase -i: fast-forward
bcfd87e HEAD@{39}: rebase -i (start): checkout master
3892bc7 HEAD@{40}: rebase -i (finish): returning to refs/heads/feature/my2
3892bc7 HEAD@{41}: commit (amend): prevent user from changing their email and cleanup web config
39262b5 HEAD@{42}: rebase -i: fast-forward
bcfd87e HEAD@{43}: rebase -i (start): checkout master
39262b5 HEAD@{44}: rebase -i (abort): updating HEAD
39262b5 HEAD@{45}: rebase -i (abort): updating HEAD
bcfd87e HEAD@{46}: reset: moving to HEAD~
39262b5 HEAD@{47}: rebase -i: fast-forward
bcfd87e HEAD@{48}: rebase -i (start): checkout master
39262b5 HEAD@{49}: rebase -i (finish): returning to refs/heads/feature/my2
39262b5 HEAD@{50}: rebase -i (pick): prevent user from changing their email and cleanup web config
bcfd87e HEAD@{51}: commit (amend): add sql scripts for database
7080968 HEAD@{52}: rebase -i: fast-forward
dfef724 (master) HEAD@{53}: rebase -i (start): checkout master
172db2b (feature/my) HEAD@{54}: checkout: moving from feature/my to feature/my2
172db2b (feature/my) HEAD@{55}: commit: prevent user from changing their email and cleanup web config
7080968 HEAD@{56}: commit: add sql scripts for database
dfef724 (master) HEAD@{57}: checkout: moving from master to feature/my
dfef724 (master) HEAD@{58}: commit (initial): init
Now you can search for your last action before starting the interactive rebase session and revert back to that state. For instance:
git reset --hard HEAD@{18}
So, let's fight against our fear and keep our Git history clean! :-)
Top comments (10)
Something I’d love to see edited into this is you can specify any commitish when interactively rebasing. When I’m squashing I almost always use
git rebase -i origin/master
. This way, you automatically get the right number of commits.Hi Christopher
That's a good point. But careful if your branch doesn't originate from master :-)
Fair point. I’ve been practicing trunk based development for quite some time now.
I declare the rebase point only if it has not been pushed yet. E.g. when I rebase only the last half of my local commits. Otherwise I keep it implicit.
GitHub has a feature squash-and-merge that works very well with team development. Devs create a feature branch for an issue and make as many commits as they want without any tidying. When the issue is finished you squash-and-merge it and it lands back onto the main integration branch as a single commit. The full history can still be seen on the feature branch. On the main integration branch the history shows when feature landed as an atomic commit which is always far more helpful than seeing the work in progress commits of the feature branch. Using this technique the history of a large project can be kept extremely clean with zero effort.
Not always useful as a feature branch may contain multiple units of work that could benefit from seperate commits. Really just depends on the scope of project and workflow.
sure. we prefer to break down work into small units that can be continuous integrated if we can. sometimes something large cannot. shipping something bigger in chunks hidden by a feature flag can reduce the integration risks. many teams work in sprints and try hard to make atomic tasks that can land as single commit where possible.
I really prefer to be able to trace each commit through history. That's pretty much the only time I practically need a history.
Sure, it's nice when it looks all tidy and orderly, but you lose information.
Can't have dirty garbage 😘
Couldn't help but mention
git reset - -hard ORIG_HEAD!