Git is one of the most powerful tools a developer can learn, yet it's also responsible for countless hours of frustration. A single incorrect command can overwrite work, create difficult merge conflicts, or even make you believe your code is lost forever.
The good news is that most Git disasters are preventable. Understanding a few common mistakes can save you hours of debugging, recovery, and unnecessary stress.
In this article, we'll cover the Git mistakes developers make most often, explain why they happen, and show you the right way to avoid them.
1. COMMITTING DIRECTLY TO MAIN
This is the mistake almost every developer makes early on. You're working on a quick fix—"just one line"—and you commit straight to main. Then someone else does the same. Before long, main is a chaotic mix of half-baked changes, broken features, and no meaningful history.
Why it's dangerous:
- It makes it nearly impossible to isolate features or bugs
- Reviewing changes before they reach production becomes difficult
- One bad commit can break everything for the entire team
The right way:
Always create a branch for every piece of work—no matter how small.
git checkout -b fix/login-button-alignment
# ... make your changes ...
git commit -m "fix: correct login button alignment on mobile"
git push origin fix/login-button-alignment
# then open a Pull RequestA good rule of thumb: if it touches code, it gets a branch.
2. FORGETTING TO PULL BEFORE PUSHING
You've been working on a feature for a few hours. You try to push—and Git throws an error because the remote has diverged. Now you have to deal with a messy merge you didn't plan for.
Why it happens:
Your local branch is behind the remote. Someone else pushed commits while you were working, and Git can't fast-forward your changes without a merge or rebase.
The right way:
Before pushing, always pull the latest changes first:
git pull --rebase origin mainUsing --rebase instead of a plain pull keeps your history linear and avoids unnecessary merge commits. Make it a habit to pull at the start of every work session.
3. CONFUSING MERGE AND REBASE
Both merge and rebase integrate changes from one branch into another—but they do it very differently, and using the wrong one at the wrong time creates headaches.
What each does:
git merge— Creates a merge commit that joins two branch histories. Preserves the full history exactly as it happened.git rebase— Replays your commits on top of another branch, rewriting history to look linear.
When to use which:
| Situation | Recommended |
|---|---|
Integrating a feature branch into main | merge (or a squash merge) |
Keeping your feature branch up to date with main | rebase |
| Shared branches that others are working on | merge (never rebase shared history) |
| Cleaning up local commits before a PR | rebase -i (interactive) |
The golden rule: never rebase commits that have already been pushed to a shared branch. Rewriting public history causes serious problems for everyone working off that branch.
4. MISUSING GIT RESET --HARD
git reset --hard is one of the most destructive commands in Git. It moves the branch pointer and discards all uncommitted changes permanently—there's no Recycle Bin.
What happens when you run it carelessly:
# You wanted to undo your last commit but keep the changes
git reset --hard HEAD~1
# ❌ Your changes are now gone. Permanently.The right way:
Know your three modes of git reset:
# Moves the commit pointer, keeps changes staged
git reset --soft HEAD~1
# Moves the commit pointer, unstages changes but keeps files
git reset --mixed HEAD~1
# Moves the commit pointer AND discards all changes
git reset --hard HEAD~1 # ⚠️ use with extreme cautionIf you just want to undo a commit without destroying work, --soft or --mixed is almost always what you need.
5. ACCIDENTALLY FORCE-PUSHING (GIT PUSH --FORCE)
Force-pushing rewrites the remote branch history. On a shared branch, this can overwrite other people's commits—effectively erasing their work from the remote.
The catastrophic scenario:
- You rebase your branch and force-push
- Your colleague had pulled the old branch and pushed new commits
- Your force-push overwrites their commits on the remote
- Their work appears "lost" from everyone else's perspective
The safer alternative:
Use --force-with-lease instead of --force. It checks that the remote hasn't changed since you last fetched, and aborts if someone else has pushed in the meantime.
# Dangerous
git push --force origin feature/my-branch
# Much safer
git push --force-with-lease origin feature/my-branchAs a rule: never force-push to main, develop, or any branch other people are actively using.
6. IGNORING .GITIGNORE
Committing files that shouldn't be in version control is a mistake with long-lasting consequences. Build artifacts, log files, editor configs, and—most critically—environment variables and secrets end up in your repository history.
Common culprits:
node_modules/
.env
.env.local
*.log
dist/
build/
.DS_Store
.idea/Why it matters:
Once a secret is committed to a public repo, you must assume it's compromised—even if you delete it in a later commit. Git history preserves deleted files. Credential scanners run continuously on public repositories.
The right way:
Set up .gitignore before your first commit. Use gitignore.io to generate one for your stack. And if you do accidentally commit a secret, rotate it immediately—don't just delete the file.
# Generate .gitignore for Node.js + macOS
curl -sLw '\n' https://www.toptal.com/developers/gitignore/api/node,macos > .gitignore7. WRITING POOR COMMIT MESSAGES
Commit messages are the documentation of your codebase's evolution. Six months from now, "fix stuff" tells you nothing. A team of ten people writing vague messages creates a history that's impossible to navigate.
What poor commit messages look like:
fix
wip
asdf
update
fixed the thingWhat good commit messages look like:
The Conventional Commits format is widely adopted and highly readable:
feat: add email verification on signup
fix: resolve null pointer in user profile loader
chore: update dependencies to latest stable versions
refactor: extract payment logic into dedicated service
docs: add API authentication guide to READMEThe structure:
<type>(<optional scope>): <short description>
<optional body: why, not what>
<optional footer: breaking changes, issue refs>A good commit message answers: what changed, and why? The diff already shows what—the message should explain why.
8. NOT USING BRANCHES EFFECTIVELY
Branches are free in Git. They're lightweight, fast to create, and fast to delete. Yet many developers treat them as a heavy formality and end up doing all their work on one or two long-lived branches.
Signs you're not using branches well:
- You have one branch called
devwhere everything goes - You're cherry-picking commits to move features around
- You can't easily roll back a single feature without affecting others
A practical branching model:
main ← production-ready, always stable
└── develop ← integration branch
├── feature/user-auth
├── feature/dark-mode
└── fix/cart-calculation-bugEach feature or fix lives in its own short-lived branch. When it's ready, it gets reviewed and merged. Then the branch is deleted. Simple, clean, and reversible.
9. DELETING BRANCHES BEFORE THEY'RE MERGED
After a lot of work, you delete a branch thinking you're done cleaning up—only to realize the PR was never merged, or a colleague needed that code as a reference.
The safe approach:
Only delete branches after confirming they've been merged into their target:
# Check if the branch is fully merged before deleting
git branch --merged main
# Safe local delete (fails if not merged)
git branch -d feature/old-feature
# Force delete (bypasses the merge check - use with caution)
git branch -D feature/old-featureOn platforms like GitHub, enable the "automatically delete head branches" setting after PR merges—this ensures branches are deleted at the right time, automatically.
10. SWITCHING BRANCHES WITHOUT STASHING OR COMMITTING
You're deep in a feature when you need to jump to another branch to check something. You switch, and Git either refuses or silently carries your uncommitted changes with you—potentially mixing them into the wrong branch.
The right habit:
Use git stash to temporarily shelve changes:
# Save your current work-in-progress
git stash push -m "WIP: half-done login form refactor"
# Switch to the other branch, do your thing
git checkout hotfix/urgent-bug
# ... fix the bug, commit, push ...
# Come back and restore your stash
git checkout feature/login-form
git stash popYou can also stash multiple times and list them:
git stash list
# stash@{0}: WIP: half-done login form refactor
# stash@{1}: WIP: dark mode styles
git stash apply stash@{1} # apply a specific stash without removing it11. RECOVERING "LOST" COMMITS WITH GIT REFLOG
Here's the part most developers don't know: Git almost never actually deletes your commits. Even after a git reset --hard, a bad rebase, or an accidentally deleted branch, your commits are almost certainly still there—just unreachable.
git reflog is your time machine:
The reflog records every position HEAD has been in, regardless of the current branch history.
git reflog
# HEAD@{0}: reset: moving to HEAD~3
# HEAD@{1}: commit: add dark mode toggle
# HEAD@{2}: commit: fix sidebar layout
# HEAD@{3}: commit: update nav stylesIf you accidentally reset away several commits, you can recover them:
# Restore HEAD to where it was before the reset
git reset --hard HEAD@{1}
# Or, cherry-pick a specific "lost" commit onto your current branch
git cherry-pick a3f8c21Important: The reflog is local and entries expire after 90 days by default. Act quickly after a disaster.
12. BEST PRACTICES FOR A CLEAN GIT HISTORY
Beyond avoiding individual mistakes, here are the habits that separate a messy Git history from a clean, navigable one:
Commit small and often
Each commit should represent one logical change. Smaller commits are easier to review, easier to revert, and easier to understand months later.
Use interactive rebase to clean up before merging
Before opening a PR, squash or reorder your WIP commits into a clean, logical sequence:
git rebase -i origin/mainUse tags for releases
git tag -a v1.4.0 -m "Release 1.4.0: adds dark mode and improved search"
git push origin v1.4.0Protect your main branch
On GitHub/GitLab, enable branch protection rules:
- Require pull request reviews before merging
- Require status checks to pass
- Disallow force-pushes
- Require linear history
Review your own PR before assigning reviewers
Read your diff top-to-bottom before you request a review. You'll catch obvious issues, remove debug statements, and write better PR descriptions—saving everyone's time.
FINAL THOUGHTS
Git mastery isn't about memorizing every command. It's about understanding why things go wrong and building habits that prevent them. The mistakes above aren't signs of bad engineering—every developer has made them. The ones who make them twice just needed better habits.
The short checklist that will save you hours:
- ✅ Always branch off
mainfor new work - ✅ Pull before you push
- ✅ Use
--force-with-leaseinstead of--force - ✅ Set up
.gitignorebefore your first commit - ✅ Write descriptive commit messages
- ✅ Stash or commit before switching branches
- ✅ Know that
git reflogcan recover almost anything
And when disaster does strike? Don't panic. Check the reflog first. You probably haven't lost anything.
Have a Git horror story of your own? I'd love to hear it. Reach out on Twitter or Telegram.

Sign in to join the discussion.