Force Push Wiped Commits — Version Control Best Practices
Bare git push --force on main can silently delete teammates' committed work.
20+ years shipping production systems from the metal up. Written from production experience, not tutorials.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- Git snapshots your project with every commit – think of it as a save point with a note.
- One commit = one logical change; never mix unrelated changes in a single commit.
- Branches isolate work: never commit directly to main, always use feature branches.
- Use merge for shared branches (preserves history), rebase for private branches (clean linear history).
- Keep branches short-lived (1-2 days) to avoid nightmare merges.
- Write commit messages in imperative mood: "Fix login bug" not "Fixed login bug" or "fixes".
Version control best practices are the operational habits and conventions that keep a Git repository healthy, readable, and recoverable — especially when things go wrong, like a force push that wipes commits. At its core, version control isn't just about saving files; it's about creating a reliable, auditable history of changes that multiple developers can collaborate on without stepping on each other.
The practices cover everything from how you structure commits (small, atomic, descriptive) to how you manage branches (short-lived, purpose-named) and how you integrate work (merge vs. rebase tradeoffs). They also include the mundane but critical rituals: maintaining a .gitignore to keep junk out of the repo, writing a README that actually helps, and resolving merge conflicts systematically instead of panicking.
These practices exist because Git gives you immense power — and immense rope to hang yourself. A single git push --force can erase hours of work from a shared branch if you haven't agreed on conventions. Companies like GitHub, GitLab, and Bitbucket all enforce or encourage specific workflows (GitHub Flow, Git Flow, trunk-based development) precisely to prevent these disasters.
The alternative is chaos: massive commits with no context, long-lived branches that diverge into merge hell, and teammates who don't know which branch is stable. When you skip these practices, you're not just being sloppy — you're creating technical debt that compounds every time someone runs git pull.
Where this fits in the ecosystem: version control best practices are the layer above Git commands. They're not about syntax (you can look up git rebase -i flags) but about judgment — when to rebase vs. merge, how to name a branch so it's self-documenting, and what to do when a force push wipes your colleague's commits.
You don't need these practices for a solo project or a throwaway prototype. But the moment you have two developers or a production deployment, they're non-negotiable. Tools like Husky (pre-commit hooks), commitlint (enforcing commit message formats), and branch protection rules on GitHub are all implementations of these practices.
Without them, you're one --force away from a bad day.
Imagine you're writing a 30-page school essay in Google Docs. Every time you finish a paragraph, Google secretly saves a snapshot — so if you accidentally delete three pages, you can rewind to yesterday's version in seconds. Version control is exactly that snapshot system, but for code. Instead of Google doing it automatically, you decide when to save a snapshot, what to name it, and who else can see it. Every professional software team on Earth uses this — and the habits you build around it will define how trustworthy you look as a developer.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Every software project eventually becomes a time machine problem. You ship a feature on Monday, a bug report lands on Wednesday, and by Friday you genuinely can't remember what the code looked like before you touched it. Without a disciplined approach to version control, that's not a inconvenience — it's a crisis. Teams lose work, bugs get shipped twice, and developers overwrite each other's changes without ever knowing it happened. This is not a rare edge case. It happens on real projects, at real companies, every single week.
Version control solves this by giving every change a permanent address in history. Every time you save a meaningful checkpoint (called a commit), the tool records exactly what changed, who changed it, and when. You can compare any two points in history, undo a disastrous change in seconds, and let ten developers work on the same codebase simultaneously without stepping on each other's toes. The tool most teams use today is Git — but the practices around Git are what separate senior engineers from people who just know the commands.
By the end of this article you'll understand what a commit really is and how to write one that makes sense six months later, how to use branches to work safely without breaking anything, how to think about merging versus rebasing, and the three common mistakes that silently wreck team codebases. You don't need any prior experience — just an appetite to build habits that will make every team you join immediately trust your work.
What a Commit Actually Is — And Why Tiny Commits Win Every Time
A commit is a permanent snapshot of your project at a specific moment. Think of it like a save point in a video game — except each save point also has a label you wrote, explaining exactly what changed and why. The label is called a commit message, and it's more important than most beginners realise.
The golden rule is: one commit, one logical change. Not one commit per day. Not one giant commit when the feature is done. One commit per idea. If you fixed a login bug and also tweaked a button colour, those are two separate commits — even if you did both in the same five minutes. Why? Because six months later, when a colleague hunts down the bug that the button-colour change introduced, they need to be able to isolate it instantly.
A good commit message follows this structure: a short subject line (under 50 characters) that completes the sentence 'If applied, this commit will...' — followed by a blank line and an optional body explaining why, not what. The diff already shows what changed. The message should explain the reasoning a future developer won't have access to.
Small, focused commits are also much easier to review in a pull request, much easier to revert if something goes wrong, and they make git blame (a command that shows who last touched each line) genuinely useful instead of pointing at one massive commit that changed 800 lines.
# --- STEP 1: Check which files you've changed --- git status # Output shows modified files — review this before staging anything # --- STEP 2: Stage ONLY the files related to one logical change --- # Bad habit: git add . (stages everything at once — loses granularity) # Good habit: stage file by file, or by hunk git add src/auth/login_validator.py # Only staging the login fix — not the button colour change # --- STEP 3: Confirm exactly what you're about to commit --- git diff --staged # Shows line-by-line what is staged — always read this before committing # --- STEP 4: Write a commit message that explains WHY, not just WHAT --- git commit -m "Fix login validator rejecting valid emails with plus signs" # Subject line: under 50 chars, imperative mood, no full stop at the end # --- STEP 5: Stage the second unrelated change as its own commit --- git add src/components/submit_button.css git commit -m "Update submit button colour to match new brand guidelines" # --- STEP 6: View the clean, readable history you just created --- git log --oneline # Output: # a3f9c12 Update submit button colour to match new brand guidelines # 7e8b401 Fix login validator rejecting valid emails with plus signs # 1d2a9ff Add password strength indicator to registration form
git add -p (patch mode) to stage individual chunks within a single file — not the whole file at once. This is the move when you've made two unrelated changes in the same file and want to split them into separate commits. Interviewers who dig into Git deeply will be impressed if you mention this unprompted.Branching Strategy — How to Work Without Breaking Everything
A branch is a parallel universe for your code. The main branch (usually called main or master) represents the code that's live, working, and trusted. A feature branch is a copy of that universe where you can experiment, break things, and rebuild them — without touching the version everyone else is relying on.
The core idea is simple: never commit directly to main. Every piece of work — no matter how small — gets its own branch. You work there, you test there, you get it reviewed there, and only then does it merge back into main. This keeps main in a permanently deployable state, which is the entire point.
Branch names should be descriptive and follow a consistent pattern. A common convention is type/short-description — for example: feature/user-profile-page, bugfix/cart-total-rounding-error, or hotfix/payment-gateway-timeout. This tells every teammate at a glance what type of work is happening and what it's about, without opening a single file.
Keep branches short-lived. A branch that lives for three weeks becomes a nightmare to merge because the main branch has moved on. Aim to open a pull request within a day or two of starting a branch. If a feature is too large to finish that quickly, break it into smaller deliverable pieces — that's a design skill, not just a Git skill, and it signals seniority.
# --- Always start from an up-to-date main branch --- git checkout main git pull origin main # Pulling first ensures your new branch starts at the latest point, # not a stale snapshot from yesterday # --- Create and immediately switch to your feature branch --- git checkout -b feature/user-profile-avatar-upload # The -b flag creates the branch AND switches to it in one step # Naming convention: type/kebab-case-description # --- Do your work, committing in small logical chunks --- git add src/profile/avatar_uploader.py git commit -m "Add image format validation to avatar uploader" git add src/profile/avatar_uploader.py git commit -m "Add file size limit of 5MB for avatar uploads" git add tests/profile/test_avatar_uploader.py git commit -m "Add unit tests for avatar upload validation rules" # --- Push your branch to the remote so teammates can see it --- git push -u origin feature/user-profile-avatar-upload # The -u flag sets the upstream tracking — after this you just use 'git push' # --- Check the current state of your branches --- git branch -a # Output: # main # * feature/user-profile-avatar-upload # remotes/origin/main # remotes/origin/feature/user-profile-avatar-upload # --- When the pull request is approved, delete the branch cleanly --- git checkout main git pull origin main git branch -d feature/user-profile-avatar-upload # -d (lowercase) only deletes if it's already been merged — a safe guard
main is moving forward without you. After two weeks, merging your branch can feel like defusing a bomb — conflict after conflict, context you've forgotten. The fix isn't to merge faster carelessly; it's to make branches smaller. If a feature takes three weeks, it should probably be three separate branches merged one at a time.Merge vs Rebase — Choosing the Right Way to Combine Work
Once your feature branch is ready, you need to bring it back into main. There are two ways to do this: merge and rebase. They both achieve the same end result — your code ends up in main — but they create very different histories, and understanding the difference is a genuine mark of seniority.
A merge takes both branches and creates a new 'merge commit' that ties them together. History is preserved exactly as it happened — parallel work looks parallel in the log. It's honest, non-destructive, and safe for branches that other people are also working on. The downside is that a project with lots of branches and merges can produce a git log that looks like a tube map — hard to read linearly.
A rebase replays your branch's commits on top of the latest main, one by one, as if you had started your branch today instead of a week ago. The history comes out perfectly linear — no merge commits, no diverging lines. It's much easier to read. The downside: rebase rewrites commit hashes, which means if anyone else has your branch checked out, their history will conflict. The rule of thumb is: never rebase a branch that other people are working on.
The most common professional workflow is: rebase your feature branch on top of main before opening a pull request (to keep history clean), then use a regular merge (or a 'squash merge') when the pull request is approved. This gives you the readability of rebase with the safety of merge at the critical moment.
# ============================================================ # SCENARIO: Your feature branch is behind main by 3 commits. # You want to update your branch before opening a pull request. # ============================================================ # --- Option A: MERGE (preserves full history, safe for shared branches) --- git checkout feature/search-filter-improvements git merge main # Git creates a merge commit that joins the two histories # Your log will show a 'Merge branch main into feature/...' commit # Safe to use when teammates are also on this branch # --- Option B: REBASE (clean linear history, only for your own branches) --- git checkout feature/search-filter-improvements git rebase main # Git temporarily removes your commits, fast-forwards to latest main, # then replays your commits on top one by one # Your commit hashes CHANGE — never do this on a shared branch # --- If a conflict occurs during rebase, Git pauses and tells you --- # CONFLICT (content): Merge conflict in src/search/filter_engine.py # Step 1: Open the file, resolve the conflict markers manually # Step 2: Stage the resolved file git add src/search/filter_engine.py # Step 3: Continue the rebase (NOT git commit — git rebase --continue) git rebase --continue # Step 4: If you want to abandon the whole rebase and go back to before git rebase --abort # --- After rebase, push requires --force-with-lease (NOT --force) --- git push --force-with-lease origin feature/search-filter-improvements # --force-with-lease is safer than --force: # it refuses to overwrite if someone else has pushed to the branch since your last fetch # --- View how clean the rebased log looks vs a merged log --- git log --oneline --graph # Rebased output (clean, linear): # * d9f3e11 Add price range filter to search results # * c7a2b04 Add category multi-select to search sidebar # * 8e1f9a0 (origin/main, main) Add pagination to product listing
--force-with-lease to avoid accidentally overwriting others' work.--force-with-lease when you must force push..gitignore, README, and the Habits That Make Teammates Love You
The practices covered so far — clean commits, short-lived branches, thoughtful merging — are the big ones. But there's a set of smaller habits that separate developers who 'know Git' from developers who 'use Git professionally'. These habits are often what interviewers probe for when they ask 'tell me about your version control workflow.'
First: every repository needs a .gitignore file before the first commit. This file tells Git which files to completely ignore — things like compiled binaries, log files, API keys stored in .env files, and IDE configuration folders like .idea/ or .vscode/. Committing these files is at best noise and at worst a security disaster. The website gitignore.io generates ready-made .gitignore files for any language or framework.
Second: never commit credentials. Not even for a second. Even if you delete them in the next commit, they are permanently in Git history and can be extracted. Use environment variables or secret management tools instead. If you accidentally commit a secret, rotate the credential immediately — assume it's compromised.
Third: write a meaningful README. It should answer four questions: what does this project do, how do I run it locally, how do I run the tests, and who do I contact if something is broken. A project with a clear README signals a professional codebase. A project without one signals chaos.
Finally: tag your releases. When code goes to production, run git tag -a v1.4.0 -m "Release 1.4.0 — adds avatar upload and search filters". Tags create permanent, named markers in history so you can always check out exactly what was running in production on any given day.
# ============================================================ # Setting up a professional repository from scratch # ============================================================ # --- 1. Initialise the repository --- mkdir ecommerce-platform cd ecommerce-platform git init # --- 2. Create a .gitignore BEFORE your first commit --- cat > .gitignore << 'EOF' # Python compiled files — not needed in version control __pycache__/ *.pyc *.pyo # Virtual environment — each developer creates their own venv/ .env/ # Environment variables — NEVER commit secrets .env .env.local .env.production # IDE configuration — personal to each developer's setup .idea/ .vscode/ *.swp # Build output — regenerated from source, not source itself dist/ build/ *.egg-info/ # OS files .DS_Store Thumbs.db # Log files — these grow forever and belong nowhere near git logs/ *.log EOF # --- 3. Create a professional README --- cat > README.md << 'EOF' # Ecommerce Platform A Python-based ecommerce backend with product search, cart management, and Stripe payments. ## Running Locally ```bash python -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate pip install -r requirements.txt cp .env.example .env # Fill in your own values python manage.py runserver ``` ## Running Tests ```bash pytest tests/ -v ``` ## Contact Owner: platform-team@yourcompany.com EOF # --- 4. First commit with both essential files --- git add .gitignore README.md git commit -m "Initial project setup with gitignore and README" # --- 5. Tag a release when code hits production --- git tag -a v1.0.0 -m "Release 1.0.0 — initial launch with product listing and cart" git push origin v1.0.0 # Now this exact state of the code is permanently labelled # --- 6. Verify the tag exists --- git tag -l # Output: v1.0.0 git show v1.0.0 --stat # Shows the commit the tag points to, the tag message, and files changed
git log -p and see every version of every file ever committed. If you commit a secret, treat it as fully compromised — rotate it immediately, then use a tool like git filter-repo to scrub the history if it's a private repo. If it's a public repo, assume the key has already been harvested by automated scanners.Handling Merge Conflicts Like a Pro
Merge conflicts happen when two branches modify the same part of the same file in different ways. Git can't decide which version to keep, so it stops and asks you to resolve it manually. This is not a sign of failure — it's a normal part of collaborative development. But how you handle conflicts separates a smooth workflow from a chaotic one.
When a conflict occurs, Git marks the conflicted file with special markers: <<<<<<<, =======, and >>>>>>>. The section between <<<<<<< and ======= is your current branch's version; between ======= and >>>>>>> is the incoming branch's version. You edit the file to produce the correct final state, remove the markers, and stage the file.
The most common mistake during conflict resolution is to blindly accept one side without understanding the context. Always consider both changes — the answer is often a combination, not a choice. If you're unsure, talk to the developer who made the conflicting change. A five-minute conversation saves an hour of debugging later.
To reduce conflicts in the first place, keep branches short, communicate with your team about what files you're working on, and rebase frequently to stay close to main. Conflict resolution is a skill, like debugging — the more you do it deliberately, the faster you get.
# ============================================================ # Simulating and resolvin a merge conflict # ============================================================ # --- Start by attempting the merge --- git merge feature/new-header # Git outputs: Auto-merging index.html # CONFLICT (content): Merge conflict in index.html # Automatic merge failed; fix conflicts and then commit the result. # --- View the conflicted file --- cat index.html # <<<<<<< HEAD # <title>My App - Home</title> # ======= # <title>Ecommerce Platform - Dashboard</title> # >>>>>>> feature/new-header # ... rest of file # --- Resolve manually: edit the file to keep the new title --- # Final result: <title>Ecommerce Platform - Dashboard</title> # --- Stage the resolved file --- git add index.html # --- Complete the merge --- git commit -m "Merge feature/new-header: update page title" # --- Alternative: use git mergetool to open visual diff --- # git mergetool # This launches your configured diff tool (e.g., vimdiff, meld, kdiff3) # --- If you want to abort the merge entirely --- git merge --abort # This restores your branch to the state before the merge attempt
git merge --abort to go back to the state before the merge attempt. No harm done. You can then re-plan your approach – maybe rebase first to reduce conflicts.Commit Messages Are Contracts — Write Them Like Your Future Self Is in Court
A commit message isn't a diary entry. It's a changelog entry for every developer who will ever touch this codebase. That includes you at 3 AM debugging a production outage. Bad message: 'fixed stuff'. Good message: 'Prevent NullPointerException in OrderProcessor when inventory service returns empty stock list'. The WHY matters more than the WHAT. The WHAT is in the diff. The WHY is why you're reading this at all. Use imperative mood: 'Add', 'Fix', 'Refactor', not 'Added' or 'Fixing'. Keep the subject line under 50 characters. Leave a blank line. Then write the body explaining context and motivation. Tools like git blame show who wrote which line — your message is their first clue. Treat it like a deposition.
// io.thecodeforge // BAD — tells me nothing // git commit -m "fix bug" public class PaymentGateway { public boolean processPayment(Payment payment) { try { // Hours wasted finding out this returns null sometimes return gateway.authorize(payment.getAmount()); } catch (Exception e) { return false; } } } // GOOD — tells me why // git commit -m "Handle null response from gateway.authorize()" // This response: your future self thanks you. public class PaymentGateway { public boolean processPayment(Payment payment) { if (payment == null || payment.getAmount() <= 0) { log.warn("Invalid payment amount, skipping authorization"); return false; } try { Boolean result = gateway.authorize(payment.getAmount()); return result != null && result; } catch (Exception e) { log.error("Gateway authorization failed", e); return false; } } }
Tagging Releases Is Insurance — You'll Thank Me When You Need to Patch a Critical Bug
Tags are your versioned timeline. They mark releases so you can instantly grab the exact state of the codebase that shipped to production. When a critical bug surfaces in version 2.1.3, you don't need to dig through commit history. You type git checkout v2.1.3 and you're there. Use semantic versioning: MAJOR.MINOR.PATCH. v2.1.3 means breaking change, feature addition, bug fix. Annotate your tags: git tag -a v2.1.3 -m 'Release 2.1.3 — fix payment retry timeout bug'. This gives you a signed, timestamped snapshot. Push tags explicitly: git push origin --tags. They won't go on their own. Without tags, you're guessing which commit was the release. Guessing causes hotfixes that break more things.
# io.thecodeforge # Tagging a release after merging to main # First, ensure you're on main and up-to-date git checkout main git pull origin main # Create an annotated tag with metadata git tag -a v2.1.3 -m "Release 2.1.3 Fixes: - Payment retry now respects exponential backoff - Order status sync no longer duplicates records - Logging level adjusted to reduce noise" # Push the tag (pushing main doesn't push tags) git push origin main --tags # Verify on remote git ls-remote --tags origin # To rollback a release, checkout the previous tag git checkout v2.1.2
Force Push to Main Wiped Out Teammate's Commits
git push --force origin main overwrites the remote main branch regardless of who committed to it.--force instead of --force-with-lease, which would have aborted if the remote had unexpected commits. Also, branch protection on main was not enabled.git reflog on one of the affected developer's local repositories to find the lost commits. Cherry-pick those commits back onto main. Re-enable branch protection to require pull requests and prevent force pushes.- Never use bare
--forceon shared branches – always use--force-with-lease. - Enable branch protection on main to block force pushes and require PRs.
- Educate team on the difference between
--forceand--force-with-lease. - Keep local clones of teammates as recovery points.
git log to find the commit hash, then git cherry-pick <hash> onto the correct branch and git reset HEAD~1 on the wrong branch.git mergetool to open a visual diff tool, or manually edit files to resolve markers, then git add and git commit.git reflog to find the commit hash before the reset, then git reset --hard <hash>.git reflog on the remote (if accessible) or ask the teammate to git push --force-with-lease with their commits. Otherwise, recover from local clones.git reset --soft HEAD~1git status to verify changes are unstagedgit reset HEAD <file> to unstage if needed.git reset --hard HEAD~1To recover lost changes, use `git reflog` to find the old commit and `git cherry-pick`--hard if you are sure you don't need the changes.git commit --amend -m "Fixed login validation"If already pushed, you need `git push --force-with-lease`git filter-repo --path .env --invert-pathsForce push all branches: `git push origin --force --all`| Aspect | git merge | git rebase |
|---|---|---|
| History shape | Non-linear — shows branches diverging and joining | Linear — looks like one straight line of commits |
| Creates extra commits | Yes — adds a merge commit to join branches | No — replays your commits directly on top of the target |
| Safe on shared branches | Yes — does not rewrite existing commits | No — rewrites commit hashes, breaks others' local copies |
| Conflict resolution | Resolve once in the merge commit | Resolve once per replayed commit (can be more work) |
| Readability of git log | Can become complex with many branches | Clean and easy to follow chronologically |
| Best used when | Merging a completed PR into main | Updating your private feature branch before opening a PR |
| Force push needed after | No | Yes — use --force-with-lease, never bare --force |
| File | Command / Code | Purpose |
|---|---|---|
| good_commit_workflow.sh | git status | What a Commit Actually Is |
| branching_workflow.sh | git checkout main | Branching Strategy |
| merge_vs_rebase.sh | git checkout feature/search-filter-improvements | Merge vs Rebase |
| professional_repo_setup.sh | mkdir ecommerce-platform | .gitignore, README, and the Habits That Make Teammates Love |
| conflict_resolution.sh | git merge feature/new-header | Handling Merge Conflicts Like a Pro |
| CommitExample.java | public class PaymentGateway { | Commit Messages Are Contracts |
| release-tag.sh | git checkout main | Tagging Releases Is Insurance |
Key takeaways
Common mistakes to avoid
3 patternsCommitting directly to main
Writing vague commit messages like 'fix bug' or 'changes'
Committing node_modules, .env, or build artifacts
Interview Questions on This Topic
Walk me through your typical Git workflow when starting a new feature — from the moment you get the ticket to the moment the code is in production.
What is the difference between git merge and git rebase, and when would you choose one over the other on a team project?
If a teammate accidentally committed AWS credentials to a public GitHub repository, what are the exact steps you'd take in the next five minutes?
git filter-repo to scrub the sensitive file from all branches and tags. After cleaning history, I'd force push all branches and encourage all teammates to clone fresh copies to avoid using the old history. Finally, I'd audit access logs for any unauthorised use. In parallel, I'd add a pre-commit hook or use a secret scanning tool to prevent recurrence.Frequently Asked Questions
Commit every time you complete one logical, self-contained unit of work — not on a time schedule. That might mean three commits in an hour or one commit in an afternoon. The question to ask yourself is: 'Could this commit be reverted in isolation without breaking anything else?' If yes, it's a good commit boundary.
git fetch downloads the latest changes from the remote repository but does NOT apply them to your working files — it just updates your local knowledge of what the remote looks like. git pull does a fetch AND immediately merges those changes into your current branch. A safer habit is to run git fetch first, inspect what changed with git log origin/main, and then decide whether to merge — this avoids surprise conflicts landing in your code unannounced.
Learn the command line first — without exception. GUI tools hide what's really happening, and when something goes wrong (and it will), you need to understand the underlying model to fix it. Once you're comfortable with the commands, a GUI like GitKraken or the GitHub Desktop app is a perfectly reasonable addition for visualising branch history. But Git from the terminal should always be your foundation.
20+ years shipping production systems from the metal up. Written from production experience, not tutorials.
That's Software Engineering. Mark it forged?
6 min read · try the examples if you haven't