Git is awesome. That is, until someone tells you to squash rebase instead of merge and now you’ve deleted the entire codebase and brought down Fastly (not what happened, but you never know). So in this article, I’ll explain what a rebase is, compare it to a merge, and at the end provide some aliases I use to hack my speed up a notch.
A Deck of Cards
Imagine your code project represented as a deck of cards. Each card is a commit, recording changes made to the code. Commits apply sequentially, building the project incrementally. Initially, there is one main deck representing the official project history. You start committing changes, adding new cards to the top of the deck.
Eventually, you want to work on a new feature without affecting main, so you split off some cards into a separate stack – you’ve created a branch! Now there are two stacks: the main deck, and the new feature branch. This stack develops the same way, while others add cards to main. The stacks evolve independently.
Once “changes” is complete, it’s time to merge the branch back into main. This shuffles the two stacks together into one deck again. The branch cards interleave back into main – the history now contains both timelines. If, for example, another commit had been made to master while you did your work, in some other file, the commit history could now look like this:
Explaining Rebasing
However, this shuffled order could be confusing, or messy. 10 back-to-back “merge conflicts” or “changes” commit histories are not exactly valuable. The precise timing of commits doesn’t normally matter from a development view, we just want a clear sequence of changes. This is where rebasing helps. Instead of shuffling the branch cards into main, rebasing slaps them all on top (and I’ll discuss squash in a bit). The commits stay in their original order as you made them, but applied at the current HEAD of master.
The result is a clean, linear history on main. The branch work is rebased as if directly committed to main. The separate timeline is discarded in favor of straightforward progression.
When Not To Do This
Imagine you’re working on an “Ace Feature”, represented by a few cards on top of the deck. A colleague uses your Ace cards as a base for their “King Feature”, adding more cards on top. If you decide to “rebase” your Ace Feature, it will force your colleague to apply his work directly to main as if it was new. As the history was rewritten, git will not know which commits the King’s feature is derivative of, and likely throw conflict messages all over the files you both worked in.
To be extremely specific, let’s imagine that on the same line, you correct a typo after your colleague branches off. When your colleague tries to merge his change in to main, git will either see it as a conflict, or even suggest that your colleague’s PR just wants to re-add the typo. If the complexity is far beyond that of a typo, it can be difficult to spot.
Squashing Commits
Rebasing can be taken further by squashing commits. This condenses multiple granular commits from a branch into a single combined one. Before applying to main, the commits squash into one “summary card” encapsulating the changes. You could create an Ace of SpadeHeartsClubsDiamonds and win every game. This single commit then places on top of main.
The primary use case for this, besides the initial PR, is when correcting anything that PR reviewers may point out. Different variable name here, typo there, comment should go here… It’s 100% ok to keep all of those commits, especially if each one is committed with a message that accurately describes its purpose. However, this is often less than ideal, and especially in the case of merge conflicts, just boilerplate action taken before merging the actual desired feature into the codebase. In these cases, squashing allows you to preserve the single, precise commit that completes a feature or fixes a bug, without clogging the history up with a litany of vaguely related changes.
The Speed Hacks and Code
This is my alias file:
sqcms = "!f() { git add . && git commit --fixup HEAD && GIT_SEQUENCE_EDITOR=':' git rebase -i --autosquash HEAD~2 && git push -f origin -u HEAD; if [ $? -eq 0 ]; then echo 'Pushed squashed changes to origin'; else echo 'Failed to squash commits'; fi }; f" acm = "!f() { git add . && git commit -m \"$1\" & git push origin -u HEAD; }; f" scm = "!f() { git stash && git checkout master && git pull; }; f" hrt = "!f() { git fetch origin && git reset --hard origin/master; }; f" undo = "!f() { git checkout HEAD~1 -- $1 && git sqcms; }; f"
&& means that if the previous command failed, it will not run the next.
if [ $? -eq 0 ]; is just a quality of life thing so that I get a nice message if it all ran.
Run any of these once they are in your .gitconfig by typing git [alias] [arguments]
, so for example git acm "Modified squeemps to squamp the baubles"
SQCMS
This alias assumes 2 things:
- You have made a PR consisting of a single commit
- You have changes locally, and you want to push them up, changing nothing except that the PR should now have the code changes.
git add .
– Stages all the changes in the current directory.git commit --fixup HEAD
– Creates a new commit with a message indicating it’s a fix for the most recent commit (HEAD
). This new commit will be marked as “fixup”, meaning it’s meant to be squashed into another commit.GIT_SEQUENCE_EDITOR=':' git rebase -i --autosquash HEAD~2
– Starts an interactive rebase of the last two commits (HEAD~2
) but does not actually open the text editor for the todo list (due toGIT_SEQUENCE_EDITOR=':'
).--autosquash
tells Git to automatically squash any commits marked as “fixup”, which will work due to #2git push -f origin -u HEAD
– Forcibly (-f
) pushes the current branch to theorigin
remote, setting it as the upstream (-u
) branch, the one you’re working in (so don’t work in master 🙂if [ $? -eq 0 ]; then echo 'Pushed squashed changes to origin'; else echo 'Failed to squash commits'; fi
– If the previous commands were successful ($? -eq 0
), it prints a success message. Otherwise, it prints an error message.
ACM
This is a shortcut for adding all changes in the current directory, committing them with a message, and pushing the commit to the remote repository. Here’s what each part of the command does:
git add .
stages all changes in the current directory.git commit -m \"$1\"
creates a new commit with the message passed as an argument ($1
).git push origin -u HEAD
pushes the commit(s) on the current branch to theorigin
remote repository and sets it as the upstream.
SCM
This command stashes any changes you’ve made, checks out the master branch, and then updates it. Here’s the breakdown:
git stash
takes your modified tracked files and saves them on a stack so you can apply them later.git checkout master
switches to the master branch.git pull
fetches the latest changes from the remote repository and merges them into your local master branch.
HRT
This command fetches the latest changes from the remote repository and resets the local master branch to match it. Here’s the breakdown:
git fetch origin
gets the latest updates from theorigin
remote repository but doesn’t merge them.git reset --hard origin/master
resets your local master branch to match theorigin/master
branch exactly, discarding any local changes or commits.
UNDO
This command undoes changes made to a specific file in the last commit and then uses the sqcms
command to squash and push the changes. Here’s the breakdown:
git checkout HEAD~1 -- $1
undoes changes made to the file passed as an argument ($1
) in the last commit.
Closing
Hope you found that helpful, and that the aliases let you speed through the mundane of getting the code you wrote into the codebase. If you liked that, you might also like Copilot, or using ChatGPT to speed up your content writing.