Git for Solo Devs
You don't need feature branches when you're the only one working on the codebase. All those articles about git flow and feature branches and PRs and code review? That's for teams. When you're solo, most of it is overhead.
Here's how I actually use git when working alone.
Just commit to main
Seriously. No feature branches for every little thing. No PRs to yourself. Just commit to main and push.
# The whole workflow
git add .
git commit -m 'add user profile page'
git push
That's it. Three commands. If CI passes, you're deployed (assuming you have auto-deploy set up). No ceremony.
I know this goes against everything you read in those "best practices" articles. But think about what those practices are solving for: coordination between multiple people. When you're solo, you don't have merge conflicts from other developers. You don't need to isolate work-in-progress code. You don't need approval from teammates. You're the only one touching the codebase, so keep it simple.
The mental overhead of managing branches, remembering which one you're on, switching contexts, and merging back is pure waste when you're working alone. That cognitive load is better spent on actually building your product.
But write good commit messages
Just because you're not doing PRs doesn't mean you can write garbage commit messages. Future you will look at the history and thank present you.
I use conventional commits style:
git commit -m 'feat: add user authentication'
git commit -m 'fix: handle empty response from API'
git commit -m 'refactor: extract validation logic'
git commit -m 'docs: update README with setup instructions'
git commit -m 'chore: update dependencies'
The prefix tells you what kind of change it is at a glance. When you're looking at git log months later, you can quickly find what you need.
Here's what each prefix means in practice:
- feat: New functionality users will see or use
- fix: Bug fixes, anything that corrects wrong behavior
- refactor: Code changes that don't alter functionality (moving files, renaming, restructuring)
- docs: Documentation changes (README, comments, guides)
- test: Adding or updating tests
- chore: Tooling, dependencies, config files
- style: Code formatting, whitespace, semicolons (not CSS)
- perf: Performance improvements
Good commit messages are like breadcrumbs. When you're debugging an issue six months later, you'll grep through your commit history to find when a particular behavior changed. Messages like "fix stuff" or "wip" are useless. Messages like "fix: handle null user session in dashboard redirect" tell you exactly what changed and why.
Commit early, commit often
Don't wait until a feature is 'done' to commit. Commit at every logical stopping point:
git commit -m 'feat: add user model'
git commit -m 'feat: add user registration endpoint'
git commit -m 'feat: add registration form'
git commit -m 'feat: wire up form to backend'
git commit -m 'test: add user registration tests'
Why? Because if something goes wrong, you can bisect or revert to a working state. With one giant commit, you lose that granularity.
I treat commits like save points in a video game. Each one is a state I can return to if I mess something up. This has saved me countless times when I've gone down a rabbit hole refactoring something, only to realize I broke everything and need to get back to a working state.
Here's a real example: I was refactoring my database query layer to use a new ORM pattern. After two hours of changes across 15 files, I realized the new approach was causing subtle bugs in edge cases. Because I had committed before starting ("refactor: prepare to migrate query layer"), I could just run git reset --hard HEAD and get back to a working state in seconds. Then I tried a different approach.
Without that save point, I would have spent another hour trying to manually undo all my changes, inevitably missing something and creating new bugs in the process.
When I DO use branches
I'm not anti-branch. I branch when it makes sense:
Experimenting with something risky:
git checkout -b experiment/new-auth-approach
# Try stuff, might not work
# If it works: merge to main
# If it doesn't: delete the branch, no harm done
This is perfect for trying out a new library, testing a different architectural approach, or exploring an idea you're not sure will work. The branch gives you freedom to experiment without polluting your main branch's history with failed attempts.
Need to ship a hotfix while mid-feature:
# In the middle of a big feature on main
git stash
git checkout -b hotfix/urgent-bug
# Fix the bug
git checkout main
git merge hotfix/urgent-bug
git push
git stash pop
# Continue feature work
This happens more than you'd think. You're halfway through building a new feature when a user reports a critical bug in production. You need to fix it NOW, but you don't want to ship your half-finished feature. Branches let you context-switch cleanly.
Doing something scary like database migrations:
git checkout -b feature/big-schema-change
# Make changes, test thoroughly
# Only merge when confident
Database migrations, major refactors, or anything where you want to test extensively before deploying is a good candidate for a branch. You can run it in a staging environment, verify everything works, then merge to main when you're confident.
Working on multiple projects simultaneously:
Sometimes you're waiting on something external (an API key, a design asset, feedback from a client) and want to work on a different feature. Branches let you switch contexts:
git checkout -b feature/payment-integration
# Work on payment stuff
# Blocked waiting for API credentials
git checkout main
git checkout -b feature/email-notifications
# Work on email notifications
# Done with this, merge it
git checkout main
git merge feature/email-notifications
git push
# API credentials arrive
git checkout feature/payment-integration
# Continue where you left off
The key is: branches are a tool, not a ritual. Use them when they help, skip them when they don't.
Useful git aliases
I type these commands dozens of times a day, so I alias them:
git config --global alias.st status
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.cm 'commit -m'
git config --global alias.amend 'commit --amend --no-edit'
git config --global alias.undo 'reset HEAD~1 --mixed'
git config --global alias.lg 'log --oneline -15'
git config --global alias.last 'log -1 HEAD'
git config --global alias.unstage 'reset HEAD --'
Now:
- git st instead of git status
- git cm 'message' instead of git commit -m 'message'
- git amend to add to the last commit without changing the message
- git undo to undo the last commit but keep the changes
- git lg for a compact log view
- git last to see what you just committed
- git unstage file.py to unstage a file you accidentally added
These save me hundreds of keystrokes a day. The time investment to set them up pays off within a week.
Here's a more advanced one I love:
git config --global alias.wip '!git add -A && git commit -m "wip"'
Now git wip stages everything and commits it with a "wip" message. Perfect for end-of-day commits when you want to save your progress but aren't at a clean stopping point. Next day, you can git undo to continue working, or git amend with a proper message once you finish the feature.
Fixing mistakes
Things I do regularly:
Forgot to add a file to the last commit:
git add forgotten-file.py
git commit --amend --no-edit
This happens constantly. You commit, then immediately realize you forgot to add a file, or you see a typo you want to fix. --amend lets you modify the last commit as if you'd done it right the first time.
Typo in commit message:
git commit --amend -m 'correct message'
Or if you want to edit it interactively:
git commit --amend
This opens your editor so you can fix the message. Much better than living with "fix tpyo in validation" in your git log forever.
Want to undo the last commit but keep changes:
git reset HEAD~1 --mixed
This is my most-used "undo" command. It removes the last commit from history but keeps all the changes in your working directory, unstaged. Use this when you committed too early or want to break one commit into multiple smaller ones.
Want to completely undo (discard changes too):
git reset HEAD~1 --hard
Nuclear option. This deletes the commit AND throws away all the changes. Use this when you went down the wrong path and want to get back to a known good state. Be careful with --hard - there's no undo for this.
Committed to wrong branch:
# You committed to main but meant to commit to a feature branch
git checkout -b feature/correct-branch # Create branch at current commit
git checkout main
git reset --hard HEAD~1 # Remove commit from main
git checkout feature/correct-branch # Your commit is safe here
This saves you when you forget to create a branch before starting work.
Need to uncommit several commits:
git reset HEAD~3 --mixed # Undo last 3 commits, keep changes
Great for when you made a bunch of experimental commits and want to clean up before pushing.
Already pushed? Force push (only do this on your own branches!):
git push --force-with-lease
--force-with-lease is safer than --force because it won't overwrite changes if someone else pushed (relevant if you ever share branches). When you're solo, the main time you need this is after amending or undoing commits that you already pushed.
Important: only force push to branches you own. Never force push to main if it's shared or deployed from. If you do need to force push to main (which happens when you're solo and made a mistake), make absolutely sure you know what you're doing.
Recovering from disasters
Accidentally deleted important changes:
Git has your back. Even if you did git reset --hard and lost commits, they're not gone immediately:
git reflog # Shows all your recent HEAD movements
# Find the commit hash you want
git checkout <hash>
git checkout -b recovery # Save it to a new branch
reflog is like git's undo history. It tracks every time HEAD moves, even commits you "deleted". Reflog entries stick around for 30-90 days by default, so you have time to recover from mistakes.
Committed sensitive data (API keys, passwords):
If you committed a .env file with secrets and haven't pushed yet:
git reset HEAD~1 --hard # Remove the commit completely
If you already pushed, you need to:
1. Immediately rotate/invalidate those credentials
2. Remove them from history with git filter-branch or git filter-repo
3. Force push
But honestly, if secrets hit GitHub, assume they're compromised. Rotate them immediately. Cleaning git history is secondary.
Want to undo changes to a specific file:
git checkout HEAD -- path/to/file.py # Restore file from last commit
git checkout HEAD~3 -- path/to/file.py # Restore from 3 commits ago
This is perfect when you messed up one file but want to keep changes to others.
.gitignore essentials
Every project needs a .gitignore. Start with:
# Python
.venv/
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
*.egg-info/
dist/
build/
# Environment
.env
.env.*
!.env.example
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
Add project-specific ignores as you go. When you accidentally commit something that should be ignored, add it to .gitignore immediately:
# Remove from git but keep the file locally
git rm --cached path/to/file
# Add to .gitignore
echo "path/to/file" >> .gitignore
# Commit the change
git commit -m 'chore: add forgotten file to gitignore'
Pro tip: use !.env.example in your gitignore to allow committing example env files while still ignoring the real ones. This helps other developers (or future you) know what environment variables are needed.
Push after every logical unit of work
Don't accumulate commits locally. Push frequently:
- Backup: If your laptop dies, your code is safe on GitHub
- CI runs: You find out if you broke something faster
- Visibility: Your commit graph stays active (if that matters to you)
- Deploy triggers: If you have auto-deploy set up, pushing deploys your changes
I push after almost every commit unless I'm doing a series of related changes that I want to push together.
I've heard people say "only push when your code is perfect" or "make sure everything is tested before pushing". That's fine for teams where pushing triggers notifications and affects others. When you're solo, push early and often. Your CI will catch issues, and you can fix them in the next commit. The backup alone is worth it.
Real story: my laptop's SSD died unexpectedly. I lost about 3 hours of work because I hadn't pushed. Since then, I push obsessively. If my laptop exploded right now, I'd lose at most 15 minutes of work.
The tools I use
Terminal: For most git operations. It's faster once you know the commands, and aliases make it even faster. 90% of my git usage is in the terminal.
VS Code's git panel: For staging specific lines of a file (partial commits), or when I want a visual diff before committing. The ability to stage individual hunks or even individual lines is incredibly useful when you've made multiple unrelated changes in one file.
GitHub's web UI: For browsing history, comparing branches, or looking at old versions of files. The blame view on GitHub is particularly useful for seeing when and why a line changed.
You don't need fancy git GUIs. The terminal + your editor's built-in git support is enough.
Partial commits (staging specific changes)
Sometimes you've made multiple unrelated changes in a file and want to commit them separately. This is where partial staging shines:
git add -p # Interactive staging
This shows you each change and asks if you want to stage it. Options:
- y - stage this hunk
- n - don't stage this hunk
- s - split into smaller hunks
- e - manually edit the hunk
Example scenario: you added a new feature AND fixed an unrelated bug in the same file. With git add -p, you can commit the bug fix separately:
git add -p myfile.py
# Stage only the bug fix changes
git commit -m 'fix: handle null user in dashboard'
# Stage the feature changes
git add -p myfile.py
git commit -m 'feat: add export functionality'
This keeps your git history clean and makes it easier to find specific changes later.
VS Code makes this even easier - you can click individual changed lines to stage them. I use this constantly.
Viewing history effectively
Git log is powerful but overwhelming by default. Here are the views I actually use:
Compact one-line view:
git log --oneline -20
See what changed in each commit:
git log --oneline --stat -10
Visual branch graph:
git log --oneline --graph --all -20
Search commit messages:
git log --grep="auth" --oneline
See commits that changed a specific file:
git log --oneline -- path/to/file.py
See actual changes in a file over time:
git log -p -- path/to/file.py
Find who changed a line and when:
git blame path/to/file.py
These are indispensable when debugging. "When did this behavior change?" becomes answerable in seconds.
Common gotchas and how to avoid them
Detached HEAD state:
Sometimes you'll checkout a specific commit and git warns you about "detached HEAD". This just means you're not on a branch. If you make commits here, they'll be lost when you switch branches.
Fix:
git checkout -b temp-branch # Save your position to a branch
Merge conflicts (yes, even solo):
You can get merge conflicts even working alone, especially if you use multiple machines or merge long-lived branches. When you see:
<<<<<<< HEAD
your current code
=======
incoming code
>>>>>>> branch-name
Edit the file to keep what you want, remove the conflict markers, then:
git add conflicted-file.py
git commit -m 'fix: resolve merge conflict'
Accidentally committed to detached HEAD:
You made commits while in detached HEAD and now git says they'll be lost:
git branch temp-save # Create a branch from your current position
git checkout main
git merge temp-save # Merge your work back
File keeps appearing in git status after adding to .gitignore:
You need to untrack it first:
git rm --cached file-to-ignore
git commit -m 'chore: untrack file'
Real-world workflow examples
Let me show you what my actual day-to-day git usage looks like:
Starting a new feature:
git pull # Make sure I'm up to date
# Start coding
git add .
git commit -m 'feat: add password reset flow'
git push
# Continue coding
git add .
git commit -m 'feat: add password reset email template'
git push
Fixing a bug mid-feature:
# Working on feature A, user reports bug in feature B
git stash # Save work in progress
git checkout -b hotfix/login-redirect
# Fix the bug
git add .
git commit -m 'fix: redirect to dashboard after login'
git push
git checkout main
git merge hotfix/login-redirect
git push
git branch -d hotfix/login-redirect
git stash pop # Resume feature A work
Trying out a library:
git checkout -b experiment/try-new-orm
pip install new-orm
# Try it out, make changes
# Doesn't work well? No problem:
git checkout main
git branch -D experiment/try-new-orm # Delete the experiment
# Works great? Merge it:
git checkout main
git merge experiment/try-new-orm
git push
End of day cleanup:
git status # See what's uncommitted
git add .
git commit -m 'feat: wip on admin dashboard'
git push # Everything is backed up
Morning after:
git pull # In case I pushed from another machine
# Continue working
Stashing: your temporary save
Stash is like a clipboard for uncommitted changes. Use it when you need to switch contexts but aren't ready to commit:
git stash # Save everything
git stash -u # Save everything including untracked files
Do your other work, then:
git stash pop # Restore changes and remove from stash
git stash apply # Restore changes but keep in stash
You can have multiple stashes:
git stash list # See all stashes
git stash pop stash@{1} # Pop a specific stash
git stash drop stash@{0} # Delete a stash
I use stash constantly. It's perfect for "I need to fix this bug right now but I'm in the middle of something else".
When to rebase vs merge
For solo work, I prefer merge over rebase because it's simpler and safer. But rebase has its uses:
Merge (my default):
git checkout main
git merge feature-branch
Creates a merge commit. History shows branches merged. Safe and straightforward.
Rebase (when I want linear history):
git checkout feature-branch
git rebase main
git checkout main
git merge feature-branch
Makes it look like your feature branch commits happened after all main commits. Creates linear history.
Only rebase branches that you haven't pushed, or that no one else is using. Rebasing rewrites history, which causes problems if others have your commits.
For solo work, it's almost always simpler to just merge. Save rebase for when you really want clean linear history.
Setting up a new project
When starting fresh, here's my setup:
git init
echo "# Project Name" > README.md
# Create .gitignore (see earlier section)
git add .
git commit -m 'chore: initial commit'
# Create GitHub repo, then:
git remote add origin git@github.com:username/repo.git
git push -u origin main
If you're moving an existing project to git:
cd existing-project
git init
# Create .gitignore
git add .
git commit -m 'chore: initial commit'
git remote add origin git@github.com:username/repo.git
git push -u origin main
The bottom line
Solo git is simple:
- Commit to main for normal work
- Write clear, searchable commit messages
- Commit frequently (every logical stopping point)
- Push after every commit or batch of related commits
- Branch only when it actually helps you
- Use stash to switch contexts
- Don't stress about perfect commits - you can amend and fix
- Make git work for you, not the other way around
Don't overcomplicate it. Git is a tool to track your work, not a process to follow religiously. All those complex workflows you read about are solving team coordination problems you don't have.
When you're solo, git's job is simple: keep your code backed up, track changes over time, and let you experiment without fear. Everything else is optional.
The workflow I've described here has served me well across dozens of solo projects. It's simple, it's fast, and it stays out of my way. I spend my time building, not managing branches and merge strategies.
Start simple. Add complexity only when you actually need it. Most of the time, you won't.