Git is the most useful tool I use every day and the one I spent the longest pretending to understand. The friction comes from thinking of it as a magical undo system. Once you treat it for what it actually is — a directed acyclic graph of snapshots, with a few opinions about how to arrange them — everything else clicks.
This post is the workflow I’ve landed on after a decade of using Git on every kind of project from solo hobbies to small teams. It’s not GitFlow, it’s not “trunk-based with feature flags,” and it’s not “we just push to main.” It’s the middle ground that actually works.
The principles
mainis always deployable. If you wouldn’t ship the current state ofmain, fix it now.- Commits tell a story. Each one is a self-contained, reviewable change.
- History is communication. Future-you reads it more than anyone else.
- Rebase your local stuff freely. Don’t rewrite shared history.
- Push branches, not just commits. Backups are free.
That’s the whole philosophy. The rest is just commands.
Branching: keep it simple
For a solo project, two branches usually suffice:
main— always deployable.feature/<name>— short-lived branches for in-progress work.
Don’t bother with develop, release/*, or hotfix/* unless you have a real release-management need. They add ceremony without adding value for small teams.
# Start a feature
git switch -c feature/add-jwt-auth
# Hack hack hack...
git add -p # interactively stage hunks
git commit -m "Add JWT helpers in app/core/security.py"
# Hack hack hack...
git commit -m "Add /auth/login endpoint"
# When ready, merge
git switch main
git pull --rebase
git switch feature/add-jwt-auth
git rebase main
git switch main
git merge --ff-only feature/add-jwt-auth
git push
git branch -d feature/add-jwt-auth
Commit messages
The default Git advice — “summary line under 50 chars, blank line, body at 72 chars” — is good but vague. Here’s the version I actually use:
<type>(<scope>): <summary in imperative mood>
<longer explanation if needed — wrap at 72 cols>
<links to issues, PRs, related discussions>
Examples:
feat(auth): add JWT refresh token rotation
Each refresh now issues a new refresh token and invalidates the
old one. This means a stolen refresh token gets at most one use
before the legitimate user invalidates it on the next refresh.
Closes #142
fix(orm): prevent N+1 in /api/posts/ list endpoint
select_related('author') and prefetch_related('comments') were
missing on the queryset. Tested with django-debug-toolbar:
went from 1+200 queries to 3 queries on a 100-post page.
feat, fix, refactor, docs, test, chore, perf cover most cases. The exact prefix is less important than: (a) saying what changed in the summary, and (b) saying why in the body when it’s not obvious.
Squash, fixup, rebase: cleaning up
Halfway through a feature, your local commits look like:
* a1b2c3d Fix typo
* b2c3d4e Actually fix it
* c3d4e5f Add JWT login endpoint
* d4e5f6a More work in progress
* e5f6a7b WIP
You don’t want that history on main. Clean it up before you merge:
git rebase -i main
This opens an editor with all your commits. Mark each one:
pick— keep as isreword— keep but edit the messagesquash(s) — combine into the previous commit, edit the messagefixup(f) — combine into the previous commit, discard messagedrop— delete
Reorder lines to reorder commits. Save and quit, and Git replays the rebase.
Result: a clean, story-telling history that’s easy to review.
git commit --fixup — the underrated workflow
Halfway through writing a feature, you realize an earlier commit had a typo. Instead of squashing manually:
git add -p
git commit --fixup=c3d4e5f # the SHA of the commit you want to fix
Then later:
git rebase -i --autosquash main
Git automatically positions the fixup commit next to its target and squashes it. Massively faster than manual interactive rebases.
Don’t be scared of rebase
Rebase is just “replay these commits on top of that other commit.” It feels dangerous because it rewrites history, but on your local branches it’s perfectly safe:
# I'm on feature/x, main has moved forward
git fetch
git rebase origin/main
# resolve any conflicts, then
git rebase --continue
The cardinal rule: don’t rebase commits that have been pushed and that other people might have based work on. For solo work, this rule basically never applies. For team work, only rebase your own un-merged feature branches.
When you screw up: the recovery toolkit
The single most calming thing about Git is that almost nothing is truly lost. These commands will save you:
git reflog — your safety net
Every action that moves HEAD is logged. If you accidentally hard-reset, deleted a branch, or “lost” a commit, git reflog knows where it was:
git reflog
# 5a4b3c2 HEAD@{0}: reset: moving to HEAD~3
# a1b2c3d HEAD@{1}: commit: feat: add JWT helpers ← I want this back
# ...
git reset --hard a1b2c3d # back to the good state
The reflog keeps entries for ~90 days by default. Anything you’ve done in that window is recoverable.
“Oh no, I committed to main”
git reset --soft HEAD~1 # uncommit, keep changes staged
git switch -c feature/wip # move them to a feature branch
git switch main
git reset --hard origin/main # if you need main pristine
“I committed a secret”
If you haven’t pushed yet:
git reset --soft HEAD~1
# remove the secret from the file
git add -p
git commit -m "Add config (no secrets)"
If you have pushed: rotate the secret immediately. Then clean up history with BFG Repo-Cleaner and force-push. The rotation matters more than the cleanup — once a secret has been on GitHub for any length of time, assume it’s compromised.
“I want to undo a commit that was already pushed”
Don’t force-push if other people are using the repo. Instead, revert:
git revert <SHA> # creates a NEW commit that undoes the bad one
git push
The bad commit is still in history (necessary for shared repos), but its effect is undone.
A few aliases worth setting
git config --global alias.s "status -sb"
git config --global alias.l "log --oneline --graph --decorate"
git config --global alias.la "log --oneline --graph --decorate --all"
git config --global alias.ll "log --pretty=format:'%C(yellow)%h%C(reset) %C(green)%ad%C(reset) %C(bold blue)%an%C(reset) %s' --date=short"
git config --global alias.unstage "reset HEAD --"
git config --global alias.amend "commit --amend --no-edit"
git config --global pull.rebase true
git config --global rebase.autostash true
git config --global rebase.autosquash true
git config --global rerere.enabled true
A few highlights:
pull.rebase true—git pullrebases instead of merging. Cleaner history.rerere.enabled true— Git remembers how you resolved a conflict and reapplies the resolution next time. Magical for long-lived branches.rebase.autosquash true—--autosquashis the default, so--fixupcommits squash automatically.
A .gitignore you actually want
Don’t reinvent it. Use gitignore.io to generate one for your stack. For a Python + macOS project:
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.eggs/
*.egg-info/
build/
dist/
.pytest_cache/
.coverage
htmlcov/
# macOS
.DS_Store
# IDE
.vscode/
.idea/
# Env files
.env
.env.*
!.env.example
Note !.env.example — it negates the pattern above so you can commit a template.
Pull requests, even when solo
Even for solo projects, pushing a feature branch and opening a pull request to yourself has real value:
- GitHub Actions / CI runs on the PR.
- The PR view is a much better diff reader than
git diff. - The PR is a record of what and why in one place.
- Future-you can
git log --mergesand find the context.
It costs nothing and you’ll thank yourself later.
When the team grows
This workflow scales surprisingly well to small teams (~5 devs). Add:
- Code review on every PR, even tiny ones.
- CI checks must pass before merge.
- Branch protection on
main(no direct pushes, require PR + review). - Conventional Commits if you want auto-generated changelogs.
Beyond ~10 devs, you may want trunk-based development with feature flags. But that’s a different post.
The forbidden commands (for shared branches)
These commands rewrite history. Never run them on main or any branch others depend on:
git push --forcegit rebase(interactive or otherwise) on commits already pushed and usedgit commit --amendon a commit already pushedgit reset --hardon a branch others have based work on
For your own un-merged feature branches, all of these are fine. Tell the difference and you’ll be okay.
Conclusion
Git rewards a small set of habits:
- Commit often, with good messages.
- Branch for any work that takes more than a few minutes.
- Rebase locally, merge
--ff-onlyto main. - Use the reflog when things go sideways.
- Don’t memorize commands you’ll never use.
Master these and Git will quietly disappear from your daily friction list — which is exactly what a tool should do.
For more on shipping practices, see Deploying Django to Production and Docker for Python Developers .
Happy committing!
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .