Git Finally Makes Sense: Rebase, Reset, Merge and Force Push
Most developers learn Git as a list of magic spells. git pull, git push, and when something breaks, delete the folder and clone again. It works until the day you lose a teammate's work with a force push, or spend an hour inside a conflict you don't understand.
The spells only feel scary because nobody showed you the picture behind them. Once you can see what each command does to your history, Git becomes boring in the best way: predictable.
This article draws that picture for every command that matters: merge, fast-forward, conflicts, rebase, reset, revert, force push and pull. Then it shows how real teams, from startups to companies with thousands of engineers, keep one shared history clean. Every idea comes with a plain “kid version” first, then the engineer version.
Why commit history matters
commit history is your project’s diary. Every page says what changed and why. A diary full of pages that say “stuff”, “fix” and “asdf” is useless on the day you need it.
Engineer version: history is not a backup. It is a tool you query, and a clean one pays you back in six ways:
- Undo safely. Every commit is a save point you can go back to.
- Who changed it, and why?
git blamepoints to the commit; a good message explains the reason in seconds. - Find bugs fast.
git bisectdoes a binary search over history. 1,000 commits take about 10 test steps, but only if each commit is small and builds. - Free release notes. Structured messages can be turned into a changelog automatically.
- Faster reviews. Small commits with one purpose each are quick to read and approve.
- Audit trail. Banks, healthcare and large companies must prove who changed what, and when.
Everything below is about one goal: keeping that diary readable while many people write in it at the same time.
The basics: commit, branch, HEAD
Every diagram in this article uses the same three ideas.
- Commit = a photo. A snapshot of all your files at one moment. It has a unique ID (a hash like
c3f9a1e) and it remembers its parent, the photo taken just before it. - Branch = a name tag. A branch is not a folder or a copy. It is a tiny label stuck on one commit. When you commit, your label jumps forward to the new commit.
- HEAD = “you are here”. It points to the branch you are working on right now.
commits are train cars, each hooked to the one before it. A branch is just a flag saying “this car is the front of my train”.
This is the most important idea in the whole article. Almost every command just moves name tags or makes new photos. Git almost never edits an old photo; it makes a new one and moves the tag.
Where your changes live
- Working folder (your desk): files you are editing right now.
- Staging area (the packing box): what goes into the next photo. You fill it with
git add. - Local repository (your photo album): saved commits on your laptop, made with
git commit. - Remote (the shared album on GitHub): what the team sees after
git push.
Going backwards: git restore --staged file takes a file out of the box, git restore file throws away desk edits, and git fetch / git pullbring the team's new photos down to you.
Fast-forward: the easiest merge
You made commits C and D on feature. Nobody touched main in the meantime. When you run git merge feature on main, there is nothing to combine, so Git just slides the main tag forward to D. No new commit is created.
Two flags change this behaviour:
git merge --ff-only featuremeans "fast-forward or stop". If a real merge is needed, Git refuses instead of creating one.git merge --no-ff featuremeans "always make a merge commit", even when a fast-forward was possible. Some teams use it so every feature shows up as one visible bubble in history.
Now the common case. You committed D and E on feature, and meanwhile a teammate pushed C to main. The branches have diverged: each has something the other doesn't.
git switch main
git merge featureGit finds the merge base (B, the last commit both branches share), combines the changes from both sides, and creates a new commit M with two parents: C and E.
Kid version: two roads meet at a crossroads. The crossroads (M) is a new commit that remembers both roads.
Why merge is good: nothing is rewritten. Every existing commit keeps its ID, so it is always safe on branches other people use. History shows exactly when work was joined.
Why merge gets messy: do it many times a day and history becomes a tangled railway map full of “Merge branch ‘main’ into feature” commits. Tip: git log --first-parent shows only the main line and hides the noise.
Conflicts: when Git cannot decide
Git merges most changes on its own, even in the same file. A conflict happens only when two people changed the same lines in different ways. Git will not guess who is right. It stops and asks you.
Example: the shop price was 100. Arjun, on main, changed it to 90 for a festival discount. Riya, on her branch, changed it to 120. Here is the real output of git merge riya:
Welcome to our shop
<<<<<<< HEAD
price = 90
||||||| 1ea6e92
price = 100
=======
price = 120
>>>>>>> riya- Between
<<<<<<< HEADand the next marker: your side (the branch you are on). - Between
|||||||and=======: how the line looked before either change. You only see this part withmerge.conflictStyle = zdiff3(covered in the config section). It makes conflicts far easier to solve. - Between
=======and>>>>>>> riya: their side.
How to solve one:
- Run
git statusto see which files conflict. - Open each file and find the markers.
- Decide: keep one side, the other, or combine them. If you are not sure, ask the other person. A conflict is a conversation, not a coin toss.
- Delete every marker line, so only real code remains.
git add shop.txtto mark it solved, thengit commit(orgit rebase --continueduring a rebase).
Panic button: git merge --abort or git rebase --abort puts everything back exactly as it was.
How to get fewer conflicts: pull from main often, keep branches short-lived, and avoid mixing formatting changes with logic changes in the same PR.
Rebase: move your work to the newest base
Same starting point: main has C, your feature has D and E. Instead of joining roads, rebase replays your commits on top of the latest main.
git switch feature
git rebase mainGit takes the change made by D, applies it on top of C and creates D’. Then the same for E, creating E’. Your feature tag moves to E'. The old D and E are left behind, unreferenced.
Three things most people don’t know:
- New IDs. D’ and E’ are brand-new commits. Same changes, different hashes. That is why rebasing a branch someone else has already pulled breaks their copy.
- “Ours” and “theirs” flip. During a rebase conflict, “ours” is main (the base you are replaying onto) and “theirs” is your own commit. The opposite of merge. So
git checkout --theirs filekeeps your version. - Nothing is really lost. The old D and E stay in the reflog for weeks, so a bad rebase can be undone (see the reset section).
After the rebase, merging into main is a simple fast-forward and history is one straight line.
Merge vs rebase: which one?
The final files are identical either way. Only the shape of history changes.
- Merge keeps the true story (work happened in parallel), never rewrites commits, and is safe on shared branches. The cost is a busy graph.
- Rebase gives a clean straight line that is easy to read and bisect. The cost is rewritten commits, so it is only safe on your own branch.
The golden rule: rebase your own branch to tidy it and catch up with main. Merge into shared branches. Never rebase commits that other people have already pulled.
Interactive rebase: tidy up before review
Real work is messy: “add login api”, “wip”, “fix typo”, “add tests”. Your reviewer doesn’t need to see your scribbles. git rebase -i main opens a to-do list, one line per commit, oldest at the top:
pick a1 add login api
fixup b2 wip
fixup c3 fix typo
squash d4 add testsChange the verb in front of each commit, save and close:
pick: keep as isreword: keep, but edit the messagesquash: melt into the commit above, and combine the messagesfixup: melt into the commit above, and throw this message awaydrop: delete the commitedit: stop here so you can change files or split the commit
Result: one clean commit, feat(auth): add login API with tests.
Reset: rewind your branch
git reset HEAD~1 moves your branch tag one commit back, from C to B. Commit C is no longer on your branch. The only question is: where do C's changes go? That is what the three modes decide.
--soft: changes go back into the packing box (staged). Use it to redo the last commit or squash several commits into one.--mixed(the default): changes go back onto your desk (in your files, unstaged). Use it to re-pick what goes into which commit.--hard: changes go in the bin. Your files are reset to look exactly like B. Use it to throw everything away and start clean.
Two safety nets:
ORIG_HEAD. Before a reset, merge or rebase, Git writes down where you were.git reset --hard ORIG_HEADjumps straight back.git reflog. A private diary of every place HEAD has been. Committed work is almost never lost: find its line ingit reflog, thengit reset --hard HEAD@{2}(or create a branch there withgit branch rescue <sha>). Reflog entries are kept for 90 days by default, and 30 days for commits no branch points to.
Warning:
--harddeletes uncommitted changes for good; the reflog only knows about commits. It also does not touch untracked (new, never-added) files. That isgit clean's job.Revert: the safe undo for pushed commits
Commit C has a bug and it is already on GitHub. Your teammates have pulled it. You have two ways to undo it:
git resettears the page out of the diary. Everyone who already has C is now out of sync, and you need a force push. Only acceptable for commits nobody else has.git revert Cwrites a new commit that does the exact opposite of C. History is kept, you push normally, and everyone stays in sync.
On shared branches like main, revert is the only undo you should use. One more reason to squash-merge PRs: a single PR becomes a single commit, so undoing a whole feature is one git revert.
Force push: the eraser
After a rebase or --amend, your branch has new commit IDs. GitHub's copy no longer matches yours, so a normal git push is rejected. A force push tells GitHub: "throw away what you have and take mine."
Get Sathishkumar Kandaswamy’s stories in your inbox
Join Medium for free to get updates from this writer.
That is dangerous. Here is how work gets lost:
- You and Priya both work on
feature. Priya pushes her commit C. You haven't pulled it. - You rebase your copy and run
git push --force. GitHub now has your version, and Priya's C is gone from the remote. - With
git push --force-with-leaseinstead, Git first checks: "Is GitHub's branch still where I last saw it?" It isn't (C was added), so the push is rejected. You fetch, rebase onto C, push again, and C survives.
# never use plain --force. Use this:
git push --force-with-lease
# extra safety (Git 2.30+):
git push --force-with-lease --force-if-includesWhy the second flag? If your editor runs git fetch in the background, your idea of "where GitHub was" gets updated without you ever looking at Priya's commit, and the lease check passes. --force-if-includes also verifies that you actually integrated what you fetched.
Never force push to
mainor any shared branch. Teams turn on branch protection so GitHub blocks it for everyone, including admins.
git pull = fetch + combine
git pull is two commands glued together:
git fetchdownloads the team's new commits and updatesorigin/main, your local copy of where GitHub's main is. It never touches your files or your branches. Always safe.- Combine brings those commits into your branch. This is the step where teams make different choices.
The three ways pull can combine:
git pull(merge, the classic default): if you have local commits, it creates a "Merge branch 'main' of github.com/..." commit every time. This is the main source of noisy history.git pull --rebase(pull.rebase = true): replays your local commits on top of the team's. Clean straight line. This is what most teams want.git pull --ff-only(pull.ff = only): only works if you have no local commits. Otherwise it stops and lets you decide.
Modern Git asks you to choose one, which is why you may have seen the “You have divergent branches and need to specify how to reconcile them” hint.
A config that prevents most Git pain
Run each line once. Every key below works on Git 2.38 or newer (check with git --version).
git config --global pull.rebase true
git config --global rebase.autoStash true
git config --global fetch.prune true
git config --global push.autoSetupRemote true
git config --global rerere.enabled true
git config --global merge.conflictStyle zdiff3
git config --global rebase.updateRefs true
git config --global init.defaultBranch main
git config --global alias.lg "log --oneline --graph --decorate --all"What each one does:
pull.rebase true: pull puts your commits on top. No noisy merge commits.rebase.autoStash true: unfinished edits are tucked away during a rebase and put back after.fetch.prune true: branches deleted on GitHub disappear locally too.push.autoSetupRemote true: the first push of a new branch just works, no--set-upstream.rerere.enabled true: "reuse recorded resolution". Git remembers how you solved a conflict and reapplies it if the same conflict comes back.merge.conflictStyle zdiff3: conflicts also show the original code.rebase.updateRefs true: if you stack branches on top of each other, they all move together when you rebase.alias.lg:git lgdraws the real picture of your history in the terminal. Use it before and after every command in this article.
How teams work together
Commands are the easy part. The hard part is twenty people, or two hundred, writing in the same diary. Every team needs a written branching strategy: one agreed way to get code into main. There are three common ones.
Kid version: like traffic rules. It matters less whether you drive on the left or the right. It matters a lot that everyone picks the same side.
1. Trunk-based development
Everyone merges small changes into main (“the trunk”) at least once a day. Branches live for hours, not weeks. Unfinished features are merged but hidden behind feature flags, switches that keep code turned off until it is ready.
Who uses it: high-velocity web teams and very large monorepos. Google famously works this way, although on its own version-control system rather than Git.
2. GitHub Flow
Create a branch, open a pull request, get a review, merge to main, deploy. Main is always deployable. PRs are usually squash-merged.
Who uses it: most SaaS and product teams. It is the simplest strategy that works well, and a good default if you are unsure.
3. GitFlow
Two long-lived branches: main (what is released) and develop (what is coming next). Features branch off develop; release/*branches stabilise a version; hotfix/* branches patch production.
Who uses it: teams with planned, versioned releases, such as mobile apps waiting on app-store review, installed software, or embedded devices. It adds ceremony and merges, so web teams that deploy daily usually skip it.
Many teams, one repo: the safe path to main
When several teams share one repository, rules must be enforced by the platform, not by memory. Every change takes the same road:
- Short branch, named with a ticket ID like
feat/PAY-311-upi-retry. It lives one to three days. - Pull request with one purpose and a clear title. Small PRs get faster, better reviews.
- CODEOWNERS file automatically requests a review from the team that owns the changed folders. Payments code gets reviewed by the payments team.
- CI checks run tests, linting and a commit-message check. A red check blocks the merge.
- Merge queue tests each PR on top of the newest main before merging. Two PRs that pass alone but break together get caught here, not in production.
- Main is protected and always releasable.
The settings that make this work, all available in GitHub branch protection or rulesets:
- Protect main: no direct pushes, no force pushes, required reviews, required status checks.
- One merge style for the repo. Usually squash merge: one PR becomes one commit on main, which is easy to read, bisect and revert. Some teams also enable “require linear history”.
- Feature flags instead of long-lived branches.
- Tags and release branches. Tag every release (
v2.4.0). A production hotfix goes onrelease/2.4, then getsgit cherry-picked back to main so the bug doesn't return. - A commit-message convention, checked in CI.
- Delete merged branches automatically (a repository setting on GitHub).
A commit message is the label on a lunch box. “Food” helps nobody. “Riya: lemon rice, no peanuts” helps everyone.
The most widely used pattern is Conventional Commits. A message has three parts:
feat(payments): retry failed UPI callbacksBank callbacks sometimes time out during peak hours,
so orders stayed "pending". Retry 3 times with backoff.Refs: PAY-311
Co-authored-by: Priya <priya@acme.dev>
- Header:
type(scope): subject. Imperative mood, about 50 characters, 72 at most. Test: it should finish the sentence "If applied, this commit will ___". - Body: what changed and why. Not how; the diff already shows how. Wrap lines at 72.
- Footer: ticket IDs,
BREAKING CHANGE:notes, co-authors, sign-offs.
The common types:
feat: a new featurefix: a bug fixdocs: documentation onlystyle: formatting, no logic changerefactor: same behaviour, new structureperf: a performance improvementtest: adding or fixing testsbuild/ci: build system or pipelinechore: housekeeping, like dependency bumpsrevert: reverts an earlier commit
Hidden superpower: tools like semantic-release read these types and work out the next version for you. fix bumps the patch version (1.4.1), feat the minor (1.5.0), and feat! or a BREAKING CHANGE: footer the major (2.0.0). They also write the changelog.
Other patterns you will meet
- Ticket-first (common in enterprises using Jira):
PAY-311: Retry failed UPI callbacks. Links every change to its ticket. - Kernel style (Linux and many C projects):
mm: fix race in page reclaim, with aSigned-off-by:line on every patch. - Gitmoji (popular in open source):
:bug: fix crash on empty cart. The emoji code stands in for the type.
The pattern matters less than consistency. Pick one per repository, write it in CONTRIBUTING.md, and enforce it automatically with a commit-msg hook (for example commitlint with Husky) plus a CI check.
With squash merging, the PR title becomes the commit message on main. So the PR title is the one message that must follow the convention perfectly.
Messages that tell nothing: fix, changes, wip, asdf, final final v2, updated code.
Messages that tell the story: fix(cart): stop double charge on retry, feat(auth): add Google login, docs: explain local setup.
Cheat sheet: which command, when?
Commands marked (rewrites) change commit IDs. Use them only on your own branch.
- Get the latest main into my branch:
git pull --rebase origin main(rewrites) - Land my PR on main: the squash or merge button on the PR
- Tidy my commits before review:
git rebase -i main(rewrites) - Fix my last commit, not yet pushed:
git commit --amend(rewrites) - Undo my last commit but keep the work:
git reset --soft HEAD~1(rewrites) - Take a file out of staging:
git restore --staged file - Undo a commit that is already pushed:
git revert <sha> - Push after a rebase:
git push --force-with-lease(rewrites) - Bring one fix to another branch:
git cherry-pick <sha> - Park unfinished work:
git stash, latergit stash pop - Find commits lost after a reset or rebase:
git reflog - A merge or rebase went wrong halfway:
git merge --abort/git rebase --abort
Five golden rules
- Rewrite only your own, unshared commits.
- Never force push to main.
- Pull with rebase; merge PRs with squash.
- Small commits, clear messages.
- Scared?
git reflogalmost always has your back.
Git isn’t magic. It is a diary of photos with name tags that move. Once you can see the tags, every command in this article is just “make a photo” or “move a tag”. Draw the picture, run git lg, and the fear goes away.
If this helped, share it with the teammate who still deletes the folder and clones again.






