Improving Code Reviews with Git
This is a summary of an internal presentation I gave recently at Ocula.
When I moved from iProov to Kraken, it took me a few weeks to get used to Kraken's opinionated Git conventions. iProov was a scrappy startup where getting code shipped and fixing bugs was more important than anything else, and the dev team was relatively small. Kraken on the other hand was massive and had hundreds/thousands of devs all contributing to one monorepo. At first glance, I didn't see much importance in these practices as I'd never before encountered the technical challenge that Kraken experienced with respect to the scale of its development team. Very quickly though, I saw the benefits of this system and these are my learnings from this experience.
Why Code Reviews Can Be Hard
- They can often be boring or difficult to understand
- Communicating clearly and empathetically can be challenging
- Reviewers and authors can feel vulnerable. Often both parties can experience:
- Fear of making mistakes
- Fear of questioning more senior colleagues
- Fear of receiving criticism
Still, fast and thoughtful code reviews are essential for building good software and stronger teams.
“The ratio of time spent reading versus writing is well over 10 to 1… We read code far more than we write it.”
— Uncle Bob, Clean Code
Uncle Bob's observation about reading versus writing code also applies well to code reviews - reviewers spend a lot of time reading your changes and trying to grok the what/why/how of an author's changes. This makes it worth investing extra effort in how you present those changes for the benefit of everyone.
How You Write Your Commits Can Help
Commit Message Structure
A commit message can be more than just a single line. I've found this approach helpful:
First line (keep it short and concise):
- Acts as the commit title/summary
- Imperative mood works well ("Add feature" not "Added feature")
Body (optional):
- Explain the "why" behind the change
- Provide context that isn't obvious from the code
- Reference issue numbers or tickets
- Try to wrap text at ~80 chars. (This helps with compatibility for certain git commands)
Example:
Fix user login timeout issue
Users were experiencing session timeouts during peak hours due to
database connection pooling limits. This increases the pool size
from 10 to 50 connections and adds proper error handling.
Fixes #123
Atomic Commits
An atomic commit represents a single, complete, logical change to your codebase. Think of it as the smallest possible change that makes sense on its own.
What makes a commit atomic:
- Each commit should represent one logical change
- Commits should be small but complete - they shouldn't break the build
- Tests and pre-commit hooks should pass before committing
- The change should be self-contained and not depend on other uncommitted changes
Examples of good atomic commits:
Add user authentication endpoint
Implements JWT-based authentication with proper validation
and error handling. Includes unit tests for all scenarios.
Fix pagination bug in user list
Users were seeing duplicate entries when navigating between
pages due to incorrect offset calculation in the database query.
Examples of non-atomic commits:
- "Fix bug and add new feature" (two separate logical changes)
- "Update dependencies" (if it includes unrelated code changes)
- "Work in progress" (incomplete change)
Advantages
- Easier to follow pull requests by stepping through commit by commit
- Git history becomes more meaningful
- Encourages incremental, discrete changes per commit. This forces the author to really consider what changes they're making and when
Tradeoffs
- Requires more effort and discipline from authors
- Reduces the ease of applying a quick fix as you work
- Requires familiarity with interactive rebase to get out of sticky situations (like when you realise you need to split a commit or fix a message after the fact)
Interactive Rebase
Interactive rebase is a powerful tool for rewriting commit history before sharing your work. It allows you to craft a clean, logical sequence of commits that tell a clear story about your changes.
Use git rebase -i HEAD~N
(where N is the number of commits you want to work with) to:
- Reorder commits - Move commits around to create a logical flow
- Squash commits - Combine multiple commits into one (useful for fixing typos or WIP commits)
- Split commits - Break a large commit into smaller, more focused ones
- Edit commit messages - Improve clarity and add context
- Drop commits - Remove commits that are no longer needed
Common workflow:
- Make your changes with frequent, small commits (don't worry about perfection)
- Use interactive rebase to clean up the history
- Push the polished commits for review
This approach gives you the benefits of frequent commits during development while presenting clean, atomic commits to reviewers.
Merging PRs: Squash vs. Unsquashed
This one always causes discussion. I used to be in the squash camp but now, if I'm using atomic commits, I'll keep the commits unsquashed when merging. Here are the tradeoffs:
Squash Merges
- Produces fewer entries to the Git history
- Each PR becomes a single, isolated commit
Unsquashed Merges
- Retains granularity of changes
- Easier to revert individual changes
- Supports accurate use of
git bisect
Git Bisect
Atomic commits make debugging much more effective. When each commit represents a single logical change, you can use Git bisect to pinpoint exactly which change introduced a bug.
Git bisect uses binary search to efficiently find the problematic commit. Here's an example with a typical commit history:
$ git log --oneline
a1b2c3d Fix typo in error message ← HEAD (current, broken)
d4e5f6g Add password validation rules ← potentially problematic
g7h8i9j Update user authentication endpoint ← potentially problematic
j1k2l3m Fix login form styling ← potentially problematic
m4n5o6p Refactor database connection ← potentially problematic
p7q8r9s Add user registration feature ← known working (last Friday)
Bisect process:
# Start bisect session
git bisect start
# Mark current commit as bad (has the bug)
git bisect bad a1b2c3d
# Mark a known good commit (worked last Friday)
git bisect good p7q8r9s
# Git checks out the middle commit (g7h8i9j)
# Test your application...
# Bug exists, so mark as bad:
git bisect bad
# Git now checks out j1k2l3m
# Test your application...
# No bug, so mark as good:
git bisect good
# Git identifies d4e5f6g as the problematic commit
# "d4e5f6g is the first bad commit"
# Return to original state
git bisect reset
With atomic commits, you can immediately focus on the "Add password validation rules" changes rather than hunting through a massive commit with mixed changes.
Additional Bits and Pieces I Think are Worth Sharing
These commands help maintain clean commit histories and safer workflows:
- Use
git switch
instead ofgit checkout
for switching branches. It's a newer command andgit switch
is purpose-built for switching branches, whereas git checkout is an overloaded command (its used for all sorts of things: switching branches, restoring files, creating branches, detaching HEAD, etc.) - Use
git push --force-with-lease
rather than a plain force push to avoid overwriting others' changes when rebasing
These resources are worth checking out:
- So You Think You Know Git (Feb 2024)
- So You Think You Know Git, Part 2 (Mar 2024)
man git
- RTFM. This is the best place to really learn Git- PyCharm’s Git tools. PyCharm has a suite of Git tools for diffing, grabbing changes from other branches and its own built-in history management distinct from Git, which has bailed me out of sticky situations countless times.
Finishing Thoughts
Better pull requests begin with thoughtful commits. By combining clear commit message structure, atomic commits, and interactive rebase, you create a workflow that makes code reviews more efficient and meaningful.
The investment in learning these Git practices pays dividends: reviewers can understand your changes more easily, you can debug issues more effectively, and your team builds a cleaner, more maintainable codebase together.
Lastly, I'd like to caveat all of this with the fact that whilst I think atomic commits are great, if you're just working on a solo project this might feel a bit overkill. This sort of approach really starts to shine once you have tens (or more) of developers contributing to a single project.