Skip to content

🌊 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 git commit -m "message" to just 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:

  1. Subject line (50 chars or less): Type and brief summary
  2. Blank line: Separator (critical for git log formatting)
  3. Body (wrapped at 72 chars): Detailed explanation of what and why
  4. 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


How it feels when you first start rebasing (you'll be fine)

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-stuff
  • fix
  • test123
  • temp
  • new-feature
  • fix-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-thing branch, 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 log and git blame effectively
  • 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



  1. Linus's advice on commit messages is characteristically blunt and entirely correct 

  2. This comment is a masterclass in why maintainability matters in version control 

-->