π Git in the Habit¶
Breaking three bad git habits completely transformed how I write code and collaborate with teams.
Some years ago, I decided to make a couple of changes to how I write commits and organise my git history. This post goes over how breaking three habits has vastly improved my software development and data science workflow. These aren't complex techniques requiring years of git masteryβthey're simple practices that compound over time to make you a significantly more effective developer.
Why Git Habits Matter¶
For the uninitiated, git is a distributed version control system created by Linus Torvalds in 2005. Like vim, learning git well has been transformational for my development journey. But unlike vim, most developers learn just enough git to get by:
git add .
git commit -m "fixed stuff"
git push
This works. Technically. But it's like using a Ferrari exclusively for trips to the corner shopβyou're missing out on capabilities that could make you dramatically more productive.
The three habits I'm about to share aren't about mastering obscure git commands or memorising arcane flags. They're about changing how you think about version control, and in doing so, changing how you write code.
Note
If you're looking for broader git workflow strategies, see my post on Git Branching Models which covers Git Flow, trunk-based development, and when to use each approach.
Right, OK, yeah yeahβwhat are these habits you're on about?
1. Dropping -m¶
The change: Going from to just git commit -m "message"git commit
This seems trivial. It's not.
Why -m Is Holding You Back¶
When you use git commit -m, you're limited to a single line. This encourages:
- Vague messages:
"fixed bug","updates","changes" - Incomplete context: No explanation of why the change was made
- Poor organisation: Everything crammed into one line
- Speed over quality: Quick commits without reflection
Let's look at a typical rushed commit:
git commit -m "fix login"
What does this tell you six months from now? What was broken about login? Authentication? Validation? UI? Security? You've created a commit that's essentially archaeologyβyou'll need to dig through the diff to understand what past-you was thinking.
The Power of the Editor¶
When you run git commit without -m, git opens your configured editor. This simple change unlocks:
Structured commit messages:
fix: resolve session timeout during OAuth flow
The OAuth callback was racing with session expiration, causing
users to be redirected to login after successfully authenticating
with the provider. This occurred because the session timeout was
set to 5 minutes but the OAuth flow could take up to 7 minutes
for users with slow connections.
Changes:
- Extend session timeout to 15 minutes during OAuth
- Add session refresh on successful OAuth callback
- Log session state transitions for debugging
Resolves: #342
Related: #298 (users reporting "logged out after login")
The anatomy of a good commit message:
- Subject line (50 chars or less): Type and brief summary
- Blank line: Separator (critical for git log formatting)
- Body (wrapped at 72 chars): Detailed explanation of what and why
- Footer: References to issues, breaking changes, etc.
This follows the Conventional Commits format, which I've adopted across all my projects:
<type>: <description>
<body>
<footer>
Common types: - feat: New feature - fix: Bug fix - docs: Documentation changes - refactor: Code restructuring without behaviour change - test: Adding or modifying tests - chore: Maintenance tasks (dependency updates, etc.)
Setting Up Commit Templates¶
You can make this even more powerful with git commit templates. Create ~/.gitmessage:
# <type>: <subject> (max 50 chars)
# |<---- Using a Maximum Of 50 Characters ---->|
# Body: Explain *what* and *why* (not *how*). Wrap at 72 chars.
# |<---- Try To Limit Each Line to a Maximum Of 72 Characters ---->|
# Footer: Reference issues, breaking changes, etc.
# Resolves: #123
# See also: #456, #789
# --- COMMIT END ---
# Type can be:
# feat (new feature)
# fix (bug fix)
# refactor (code change that neither fixes a bug nor adds a feature)
# docs (changes to documentation)
# test (adding or refactoring tests)
# chore (updating build tasks, package manager configs, etc.)
# --------------------
# Remember to:
# * Capitalise the subject line
# * Use the imperative mood in the subject line
# * Do not end the subject line with a period
# * Separate subject from body with a blank line
# * Use the body to explain what and why vs. how
# * Reference issues and pull requests in the footer
# --------------------
Configure git to use it:
git config --global commit.template ~/.gitmessage
Now when you run git commit, you'll see this template, making it easy to write well-structured messages.
Real-World Impact¶
Here's what changed for me after adopting this habit:
Before (actual commits from an old project):
fix bug
updates
wip
more changes
final fix
actually final this time
After:
fix: prevent race condition in cache invalidation
The cache wasn't properly locking during concurrent invalidation
requests, leading to stale data being served intermittently.
Added mutex-based locking around invalidation logic and verified
with concurrent load tests.
Resolves: #234
The difference is stark. The second approach creates a searchable, understandable project history that serves as documentation.
Common Objections¶
"This takes too long!"
It takes an extra 30 seconds. In exchange, you get: - Better code review (reviewers understand context) - Easier debugging (git blame/log actually helps) - Clearer thinking (explaining forces you to understand your changes)
"My commits are too small to need explanation"
Even tiny commits benefit from context:
# Bad
git commit -m "typo"
# Good
git commit
docs: fix incorrect parameter type in API documentation
Changed `userID` type from number to string to match actual
implementation. This was causing confusion for API consumers
who were passing integers.
2. Embrace rebase¶
The change: Using git rebase instead of git merge for updating feature branches
This one is controversial. Many developers have been burned by rebase, leading to the common advice: "never rebase public branches." This is good advice, but it leads people to avoid rebasing entirely, missing out on cleaner history.
Understanding Rebase vs Merge¶
When you merge, git creates a merge commit:
git checkout feature/new-dashboard
git merge main
This produces a history like:
* Merge branch 'main' into feature/new-dashboard
|\
| * Update dependencies
| * Fix security vulnerability
* | Add dashboard components
* | Implement data fetching
|/
* Previous commit
The merge commit adds noise. Over time, your history becomes a tangled web of merges that's difficult to follow.
When you rebase, git replays your commits on top of the target branch:
git checkout feature/new-dashboard
git rebase main
This produces a linear history:
* Add dashboard components
* Implement data fetching
* Update dependencies
* Fix security vulnerability
* Previous commit
Much cleaner. Your feature's commits appear as if you started working after the latest changes to main.
The Golden Rule of Rebasing¶
Never rebase commits that have been pushed to a public/shared branch.
This rule exists because rebasing rewrites commit history. If others have based work on your commits, rewriting them causes chaos.
Safe rebasing:
# Your local feature branch - rebase all you want
git checkout feature/authentication
git rebase main # Safe - this branch is only yours
# Clean up messy commits before pushing
git rebase -i HEAD~5 # Interactive rebase - safe on unpushed commits
Dangerous rebasing:
# DON'T rebase shared branches
git checkout main
git rebase feature/something # Dangerous if main is shared
# DON'T rebase after pushing to a PR branch that others are reviewing
git push origin feature/auth
# Someone comments on your PR
git rebase main # Problematic - breaks PR context
git push --force # Dangerous - overwrites shared history
Interactive Rebase: Your Secret Weapon¶
Interactive rebase (git rebase -i) lets you clean up commits before sharing them:
git rebase -i HEAD~4
This opens an editor showing your last 4 commits:
pick f7f3f6d feat: add login form
pick 310154e fix: typo in login form
pick a5f4a0d feat: add validation
pick c3bf0e2 fix: validation bug
# Commands:
# p, pick = use commit
# r, reword = use commit, but edit the commit message
# e, edit = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup = like "squash", but discard this commit's log message
# d, drop = remove commit
Let's clean this up:
pick f7f3f6d feat: add login form
fixup 310154e fix: typo in login form
pick a5f4a0d feat: add validation
fixup c3bf0e2 fix: validation bug
This squashes the "fix typo" commit into "add login form" and the "validation bug" fix into "add validation", resulting in:
* feat: add validation
* feat: add login form
Two clean, logical commits instead of four messy ones.
Practical Rebasing Workflow¶
Here's how I use rebase in daily development:
# Start a new feature
git checkout -b feature/api-caching main
# Work, work, work... make several commits
git commit -m "wip: initial cache implementation"
git commit -m "add redis integration"
git commit -m "fix: redis connection handling"
git commit -m "add tests"
git commit -m "fix: test flakiness"
# Before pushing, rebase onto latest main
git fetch origin
git rebase origin/main
# Clean up the commits with interactive rebase
git rebase -i HEAD~5
# In the editor, combine the WIP and fix commits:
pick abc1234 feat: implement Redis-based API caching
fixup def5678 wip: initial cache implementation
fixup ghi9012 fix: redis connection handling
pick jkl3456 test: add API caching test suite
fixup mno7890 fix: test flakiness
# Result: two clean commits ready to push
git push origin feature/api-caching
Dealing with Rebase Conflicts¶
Conflicts during rebase can be intimidating. Here's how to handle them:
git rebase main
# Auto-merging src/api.py
# CONFLICT (content): Merge conflict in src/api.py
# error: could not apply abc1234... feat: add caching
# Fix the conflicts in your editor, then:
git add src/api.py
git rebase --continue
# If you get stuck:
git rebase --abort # Gives up and returns to pre-rebase state
Warning
If a rebase goes sideways and you've already pushed with --force, you've rewritten public history. Coordinate with your team to ensure everyone re-syncs their branches. This is why the golden rule exists.
When to Merge Instead¶
Use merge when:
- Integrating long-lived branches (e.g., merging feature branch into
main) - You want to preserve the exact history of when branches diverged and converged
- Multiple people are working on the same branch
- You're following a branching model that relies on merge commits (see my branching models post)
Use rebase when:
- Updating your feature branch with latest changes from
main - Cleaning up local commits before pushing
- You want a linear, easy-to-follow history
- Working on a branch that only you touch
3. Branching with Purpose¶
The change: Using structured, hierarchical branch names instead of arbitrary ones
This might seem cosmetic, but it fundamentally changed how I think about and plan code changes.
The Problem with Arbitrary Branch Names¶
We've all seen (or created) branches like:
johns-stufffixtest123tempnew-featurefix-v2-final-actually-final
These names tell you nothing: - What is being worked on? - Is this branch still relevant? - How does it relate to other branches? - What issue does it address?
Six months later, you're running git branch -a and seeing dozens of these cryptic branches with no idea which are safe to delete.
Structured Branch Naming¶
I use a hierarchical naming convention:
<type>/<issue-number>/<brief-description>
Examples: - feature/456/user-authentication - fix/789/memory-leak-cache - refactor/234/extract-payment-service - docs/123/api-documentation
For nested work: - feature/456/issue/789/oauth-integration
This creates a navigable hierarchy:
$ tree .git/refs/heads
.
βββ main
βββ feature
β βββ 123
β β βββ dashboard-redesign
β βββ 456
β β βββ user-authentication
β β βββ issue
β β βββ 789
β β βββ oauth-integration
β βββ 789
β βββ api-v2
βββ fix
β βββ 234
β β βββ memory-leak
β βββ 567
β βββ race-condition
βββ refactor
βββ 890
βββ database-layer
Benefits of Hierarchical Branches¶
1. Discoverability
# Find all feature branches
git branch | grep feature/
# Find all branches related to issue #456
git branch | grep /456/
# Find all active refactoring work
git branch | grep refactor/
2. Relationship to issues
The issue number in the branch name creates an immediate link to your tracking system:
feature/789/api-rate-limiting
You instantly know this implements feature request #789. Your git history becomes queryable alongside your issue tracker.
3. Automatic cleanup
When an issue is closed, you know exactly which branches can be deleted:
# Issue #456 is done - find all related branches
git branch | grep /456/
# Delete them
git branch -d feature/456/user-authentication
git branch -d feature/456/issue/789/oauth-integration
4. Team coordination
When someone asks "What are you working on?", instead of:
"Oh, I'm on the
johns-branch-new-thingbranch, it's for that bug we talked about last week"
You say:
"I'm on
fix/789/session-timeout, fixing the OAuth timeout issue"
Everyone immediately understands.
Git Configuration for Easier Branch Creation¶
Set up aliases to make this pattern effortless:
# Add to ~/.gitconfig
[alias]
feature = "!f() { git checkout -b feature/$1/${2}; }; f"
fix = "!f() { git checkout -b fix/$1/${2}; }; f"
refactor = "!f() { git checkout -b refactor/$1/${2}; }; f"
Usage:
# Instead of:
git checkout -b feature/456/user-authentication
# Just:
git feature 456 user-authentication
Branch Hygiene¶
Structured naming makes branch hygiene easier. Set up regular cleanups:
# List merged branches (safe to delete)
git branch --merged main | grep -v "main" | xargs git branch -d
# List unmerged branches (review before deleting)
git branch --no-merged main
# Clean up remote tracking branches
git fetch --prune
git remote prune origin
# Find branches not updated in 30 days
git for-each-ref --sort=-committerdate refs/heads/ \
--format='%(refname:short) %(committerdate:relative)' | grep month
For automation, add to your shell profile:
# Warn about old branches on every cd into a git repo
git_check_old_branches() {
if git rev-parse --git-dir > /dev/null 2>&1; then
old_branches=$(git for-each-ref --sort=-committerdate refs/heads/ \
--format='%(refname:short) %(committerdate:relative)' | \
grep -E '(month|year)' | wc -l)
if [ "$old_branches" -gt 0 ]; then
echo "β οΈ You have $old_branches old branches. Run 'git branch --no-merged' to review."
fi
fi
}
# Add to your cd function or prompt
cd() {
builtin cd "$@"
git_check_old_branches
}
Integration with GitHub/GitLab¶
Both GitHub and GitLab support branch naming conventions in their UI:
# GitHub CLI automatically uses branch name for PR title
git checkout -b feature/456/add-dark-mode
gh pr create
# PR title: "Feature/456/add dark mode"
# (you can override, but the default is sensible)
Many teams enforce naming via branch protection rules:
# .github/workflows/branch-name-check.yml
name: Branch Name Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check branch name
run: |
branch=${GITHUB_HEAD_REF}
if [[ ! $branch =~ ^(feature|fix|refactor|docs)/[0-9]+/.+ ]]; then
echo "Branch name '$branch' doesn't follow convention"
echo "Use: <type>/<issue>/<description>"
exit 1
fi
Real-World Example¶
Here's how this looked in practice for a recent project. Note the hierarchical organisation:
$ tree .git/refs
.
βββ heads
β βββ dev
β βββ develop
β βββ gh-pages
β βββ issue
β β βββ 1
β β β βββ sbt-pkg
β β βββ 13
β β β βββ theme-change
β β βββ 2
β β βββ actions
β βββ master
βββ remotes
β βββ origin
β βββ dev
β βββ develop
β βββ gh-pages
β βββ issue
β β βββ 1
β β β βββ sbt-pkg
β β βββ 13
β β β βββ theme-change
β β βββ 2
β β βββ actions
β βββ master
βββ tags
βββ v0.1.0
βββ v1.0.0
12 directories, 16 files
Each issue/ branch maps directly to a GitHub issue, making it trivial to understand what work is in progress and what's been completed.
Takeaways¶
These three habitsβdropping -m, embracing rebase, and branching with purposeβaren't revolutionary individually. But combined, they create a workflow that's:
More maintainable: Your git history becomes documentation More collaborative: Team members understand what you're working on More productive: Less time debugging history, more time writing code
Rules of Thumb¶
For commit messages: - Use an editor, not -m - Follow conventional commits format - Explain why, not just what - Reference related issues
For rebasing: - Rebase local branches to stay updated - Interactive rebase before pushing - Never rebase public/shared commits - When in doubt, use merge
For branching: - Use hierarchical names: type/issue/description - Include issue numbers for traceability - Clean up merged branches regularly - Make branch creation easy with aliases
The Compounding Effect¶
Here's the real magic: these habits compound.
Good commit messages make rebasing easier (you know what each commit does). Structured branch names make it obvious which branches to rebase onto what. Clean history makes code review faster. Faster review means more frequent merges. More frequent merges mean less complex rebases.
Six months after adopting these practices, I found I was:
- Spending less time in code review explaining changes
- Debugging faster by using
git logandgit blameeffectively - Onboarding new team members more quickly (git history served as documentation)
- Spending zero time managing branch chaos
The investment is minimalβa few extra seconds per commit, a few minutes learning rebase. The returns are enormous.
Configuration Quick Reference¶
Here's my complete git configuration incorporating these habits. Save this to a file and use it as a starting point:
{{< gist tallamjr 325b62b697fb39a3f1e96503a5070488 >}}
This gist includes: - Commit message template - Useful aliases for structured branching - Rebase-friendly defaults - Quality-of-life improvements
References and Resources¶
On commit messages: - Conventional Commits specification - Linus Torvalds on writing good git commit messages1 - How to Write a Git Commit Message by Chris Beams
On rebasing: - Git Rebase documentation - Merging vs Rebasing
On git workflows: - My post on Git Branching Models for comprehensive workflow strategies - Why Linus Torvalds doesn't use GitHub2 (interesting perspective on git workflows)
Tools and configuration: - My git configuration gist with aliases and templates
