Intermediate Git: Fixing Mistakes and Streamlining Your Workflow

In my previous Git tutorial, we covered the basics of version control - initialising repos, making commits, pushing to remotes, and creating branches. Now it's time to wade a little deeper into Git's more powerful features. I'm going to show you how to fix mistakes, manage your work in progress, and manipulate your commit history like a pro.

Fixing Mistakes with revert and reset

Let's face it - we all make mistakes. Maybe you committed code with a bug, or accidentally included a giant log file. Git provides multiple ways to walk back these errors.

Using git revert

The revert command creates a new commit that undoes the changes made in a previous commit. It's the safest option for fixing mistakes as it doesn't alter the repository's history - it just adds a new commit that reverses the unwanted changes.

git revert <commit-hash>

This is perfect for when you've already pushed your commits to a remote repository. It's non-destructive and won't cause issues for others who have pulled your code.

Using git reset

Unlike revert, reset actually moves the current branch pointer to a different commit, effectively removing commits from your current branch history. There are three main modes:

git reset --soft <commit-hash>    # Keeps changes staged
git reset --mixed <commit-hash>   # Default - keeps changes but unstages them
git reset --hard <commit-hash>    # Discards all changes since that commit

--soft is helpful when you want to recommit your changes with a different commit message or structure. --mixed gives you a clean slate while preserving your work. --hard is the nuclear option - use it carefully as it permanently deletes uncommitted changes!

One common use of reset is to undo local commits that haven't been pushed:

git reset --soft HEAD~1   # Move back one commit but keep changes staged

⚠️ Important: Never use reset on commits that have been pushed to a shared repository. This will cause major headaches for your team as your local history will diverge from the shared history.

Temporarily Shelving Changes with stash

Ever been working on a feature when your colleague reports a critical bug that needs immediate attention? You're not ready to commit your half-finished changes, but you need a clean working directory to fix the bug. Enter git stash.

git stash save "WIP: Feature X implementation"

This command takes all your uncommitted changes (both staged and unstaged) and saves them on a temporary shelf, reverting your working directory to match the HEAD commit. After fixing that bug, you can bring your shelved changes back:

git stash apply    # Apply the most recent stash but keep it in the stash list
git stash pop      # Apply the most recent stash and remove it from the stash list

You can have multiple stashes:

git stash list                # See all stashes
git stash apply stash@{2}     # Apply a specific stash
git stash drop stash@{1}      # Remove a specific stash
git stash clear               # Remove all stashes

I've found stashing particularly handy when I need to switch branches but have uncommitted changes that would be overwritten by the checkout.

Rewriting History with rebase

Sometimes your commit history gets messy - lots of small commits, typo fixes, or "WIP" commits. Rebasing lets you clean this up by rewriting your branch's history.

The basic interactive rebase command is:

git rebase -i <commit-hash>

This opens an editor showing a list of commits since the specified hash. You can then:

  • pick - Keep the commit as is
  • reword - Change the commit message
  • edit - Stop and amend the commit
  • squash - Combine with previous commit and keep both messages
  • fixup - Combine with previous commit and discard this commit's message
  • drop - Remove the commit entirely

For example, to squash your last three commits into one cleaner commit:

git rebase -i HEAD~3

Then change pick to squash (or s for short) for the commits you want to combine, save, and edit the combined commit message.

Rebasing is also useful for incorporating upstream changes:

git fetch origin
git rebase origin/main

This is similar to git pull, but instead of merging (which creates a merge commit), it rewrites your branch's history to appear as if you started from the latest main branch.

Warning: Just like reset, never rebase commits that have been pushed and shared with others unless you're absolutely sure no one has based work on them.

Cherry-picking Commits

Sometimes you only want to grab a specific commit from another branch without merging the entire branch. That's where cherry-pick comes in.

git cherry-pick <commit-hash>

This takes the changes from the specified commit and applies them to your current branch, creating a new commit with the same message (which you can edit if needed).

It's particularly useful when:

  • You need to backport a bug fix to a release branch
  • A colleague made a useful change in their feature branch that you want in yours
  • You need to restore a specific change that was lost during a complex merge

For example, to grab a specific fix from a feature branch:

git checkout release-branch
git cherry-pick abc123def   # The hash of the bug fix commit

Practical Workflow Examples

Let's wrap these commands together in a few real-world scenarios:

Scenario 1: Oops, that last commit had a mistake!

# Fix the mistake in your code
git add .
git commit --amend   # Amends your last commit with the new changes

If you've already pushed:

git revert HEAD      # Creates a new commit undoing the previous one
git push             # Safely push the revert

Scenario 2: My feature branch got messy with lots of small commits

git rebase -i HEAD~5   # Assuming you want to clean up the last 5 commits
# Change commits to 'squash' or 'fixup' as needed
git push --force-with-lease  # Only push if no one else has updated the branch

Scenario 3: I need to switch tasks but my work isn't ready to commit

git stash save "Halfway through refactoring user authentication"
git checkout -b hotfix/critical-bug
# Fix the bug
git commit -m "Fix critical bug in payment processing"
git checkout previous-branch
git stash pop   # Resume where you left off

Scenario 4: I need a specific bugfix from another branch

git cherry-pick 789abcdef   # Apply just that commit to your current branch

Pro Tips

  1. Always name your stashes with descriptive messages. Future you will thank past you.
  2. Use git reflog to find commit hashes after a bad reset or rebase.
  3. Consider --force-with-lease instead of --force when pushing rewritten history - it's safer.
  4. Combine git add -p with these commands to selectively stage parts of files.
  5. Keep in mind that rebase and reset change commit hashes, so make sure you're not disrupting collaborators.

Wrapping Up

These intermediate Git commands give you more control over your workflow and can help you maintain a cleaner, more purposeful commit history. Remember that with great power comes great responsibility - especially when rewriting history that others might be working with.

In the next tutorial, we'll dive into more advanced techniques like submodules, bisect, and hooks. Until then, happy committing!