How I manage my Git history
posted by Ayush Newatia
26 May, 2023
I’m generally a rather pedantic person and this is supercharged when it comes to managing the git history on my projects. I used GitHub’s squash and merge for a while before Chris Moore taught me a few tricks.
I’m not a fan of squash and merge because it squashes an entire Pull Request into a single commit, no matter how large it is. This means that rather large changes could live under a single commit. I believe the commit history should tell a linear story, as pretentious as that might sound.
A clean commit history also makes life easier for code reviewers. A glance at the commits should give an overview of the changes and viewing the diff for each individual commit should make logical sense, allowing the reviewer to peruse the PR in smaller chunks.
In the long term, it’s useful to delve into a project’s history and see how and why certain changes were main. A carefully curated git history makes this easier.
I primarily use two git features to manage my commit history: git reset --mixed
and git rebase -i
(interactive rebase).
Git reset
Let’s say we’re working on a branch called feature/dashboard
which is based on the main
branch. While I’m building out a feature, my git history is a complete mess. It’s usually something like:
- WIP
- Some stuff
- Fix shit
- Complete dashboard
Yeah, really. That’s definitely not shippable, so I run:
$ git fetch
$ git reset --mixed origin/main
This will reset the state of the branch to main
and retain all my changes in the working copy (the branch needs to be up to date with main
before this, use rebase or merge). I’ll then commit them in a logical order to produce something like:
- Add new dashboard layout
- Add new assets
- Stimulus controller for drawer animations
- Create dashboard controller actions and views
That’s more like it. A reviewer can just see the commit messages and instantly have an idea of what’s changed. The diff for each individual commit also makes sense. Since we’ve rewritten the git history, we’ll need to force push this branch using:
$ git push --force
At this point, I’ll have invariably forgotten to run Rubocop and any other linters used on the project. After running those, I’d need to add another commit with the fixes but that messes up the linear story. This is when I use interactive rebase.
Interactive rebase
Interactive rebase lets us pick and choose which commits to retain on the branch, and also lets us squash multiple commits together. Running git rebase -i origin/main
opens up a text editor containing the commits which exist on the current branch, but not on main
:
pick 6246a52ba Add new dashboard layout
pick 0fd58314b Add new assets
pick 25d5bafa3 Stimulus controller for drawer animations
pick 1432fc34a Create dashboard controller actions and views
pick cbf35b39f Rubocop and ESlint
# Rebase e88d9298b..cbf35b39f onto e83d9294b (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 [-C | -c] <commit> = like "squash" but keep only the previous
# commit's log message, unless -C is used, in which case
# keep only this commit's message; -c is same as -C but
# opens the editor
# 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
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
# to this position in the new commits. The <ref> is
# updated at the end of the rebase
#
# 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.
#
All the available commands are conveniently documented within this file. I’d now use the fixup
command to squash the linter fixes into one of the other commits where it makes logical sense. Closing the file completes the rebase. Once again, we’ve rewritten history so a force push is required: git push --force
.
Other uses of interactive rebase
As a contractor, I often work on a fork of the main project, or off a separate branch rather than main
depending on my client’s requirements. This means sometimes the base branch I’m working off has been rebased.
For example, if I’m working off an integration
branch and it was rebased to bring it up to date with main
, I need to now bring my local integration
branch in sync with the remote branch.
$ git fetch
$ git checkout integration
$ git reset --hard origin/integration
Ensure you don’t have any changes on your local integration
branch when you do this or you will lose them. Next, I need to rebase my feature branch to bring it up to date with integration
. Since rebase rewrites history, there could be duplicate commits on the feature branch that have snuck in there because their hash changed after integration
was rebased. These need to be excluded these from the feature branch as it should only contain changes related to that feature.
$ git fetch
$ git checkout feature/dashboard
$ git rebase -i integration
This might now produce something like:
pick 2342bcaf3 Fix CI
pick 6246a52ba Add new dashboard layout
pick 0fd58314b Add new assets
pick 25d5bafa3 Stimulus controller for drawer animations
pick 1432fc34a Create dashboard controller actions and views
pick cbf35b39f Rubocop and ESlint
# Rebase e88d9298b..cbf35b39f onto e83d9294b (3 commands)
#
# ...
Wait a sec, that Fix CI
commit has nothing do to with this work. That’s snuck in because it lives in integration
and its hash changed during the rebase. To exclude it, delete that line and close the file so the branch is brought up to date and only contains the intended changes. It’ll need a force push once again.
Conclusion
Rewriting git history is risky. If you’re unsure about what you’re doing, ALWAYS create a backup copy of your branch before attempting destructive changes.
Never ever force push to branches used by your entire team unless you’re all aware that this is common practice and know how to deal with it. If you’re working alongside another developer on a feature branch, ensure you’re on the same page regarding force pushing and communicate force pushes promptly so neither of you accidentally lose any work.
Despite having used git for over 10 years now, and this particular workflow involving interactive rebase for 3 years or so, I still don’t really understand it properly. I’ve developed a feel for it with extended use. If you’re a bit confused, don’t worry about it, it confused the hell out of me initially and it still does, just to a lesser degree. You’ll get the hang of it with practice. It’s a great power tool to have in your arsenal.