Sitemap
Sathishkumar Kandaswamy

Sathishkumar Kandaswamy

I strive to continually expand my knowledge and actively contribute as a member of the software world, working towards shaping the real world in meaningful ways

Git Finally Makes Sense: Rebase, Reset, Merge and Force Push

15 min read3 days ago

--

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

Press enter or click to view image in full size

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 blame points to the commit; a good message explains the reason in seconds.
  • Find bugs fast. git bisect does 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

Press enter or click to view image in full size

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

Press enter or click to view image in full size
Changes travel through four places:
  1. Working folder (your desk): files you are editing right now.
  2. Staging area (the packing box): what goes into the next photo. You fill it with git add.
  3. Local repository (your photo album): saved commits on your laptop, made with git commit.
  4. 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

Press enter or click to view image in full size

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 feature means "fast-forward or stop". If a real merge is needed, Git refuses instead of creating one.
  • git merge --no-ff feature means "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.
Press enter or click to view image in full size

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 feature

Git 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

Press enter or click to view image in full size

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 <<<<<<< HEAD and the next marker: your side (the branch you are on).
  • Between ||||||| and =======: how the line looked before either change. You only see this part with merge.conflictStyle = zdiff3 (covered in the config section). It makes conflicts far easier to solve.
  • Between ======= and >>>>>>> riya: their side.

How to solve one:

  1. Run git status to see which files conflict.
  2. Open each file and find the markers.
  3. 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.
  4. Delete every marker line, so only real code remains.
  5. git add shop.txt to mark it solved, then git commit (or git rebase --continue during 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

Press enter or click to view image in full size

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 main

Git 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 file keeps 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?

Press enter or click to view image in full size

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

Press enter or click to view image in full size

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 tests

Change the verb in front of each commit, save and close:

  • pick: keep as is
  • reword: keep, but edit the message
  • squash: melt into the commit above, and combine the messages
  • fixup: melt into the commit above, and throw this message away
  • drop: delete the commit
  • edit: 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

Press enter or click to view image in full size

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_HEAD jumps straight back.
  • git reflog. A private diary of every place HEAD has been. Committed work is almost never lost: find its line in git reflog, then git reset --hard HEAD@{2} (or create a branch there with git branch rescue <sha>). Reflog entries are kept for 90 days by default, and 30 days for commits no branch points to.

Warning: --hard deletes uncommitted changes for good; the reflog only knows about commits. It also does not touch untracked (new, never-added) files. That is git clean's job.

Revert: the safe undo for pushed commits

Press enter or click to view image in full size

Commit C has a bug and it is already on GitHub. Your teammates have pulled it. You have two ways to undo it:

  • git reset tears 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 C writes 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

Press enter or click to view image in full size

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:

  1. You and Priya both work on feature. Priya pushes her commit C. You haven't pulled it.
  2. You rebase your copy and run git push --force. GitHub now has your version, and Priya's C is gone from the remote.
  3. With git push --force-with-lease instead, 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-includes

Why 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 main or any shared branch. Teams turn on branch protection so GitHub blocks it for everyone, including admins.

git pull = fetch + combine

Press enter or click to view image in full size

git pull is two commands glued together:

  1. git fetch downloads the team's new commits and updates origin/main, your local copy of where GitHub's main is. It never touches your files or your branches. Always safe.
  2. 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

Press enter or click to view image in full size

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 lg draws the real picture of your history in the terminal. Use it before and after every command in this article.

How teams work together

Press enter or click to view image in full size

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

Press enter or click to view image in full size

When several teams share one repository, rules must be enforced by the platform, not by memory. Every change takes the same road:

  1. Short branch, named with a ticket ID like feat/PAY-311-upi-retry. It lives one to three days.
  2. Pull request with one purpose and a clear title. Small PRs get faster, better reviews.
  3. CODEOWNERS file automatically requests a review from the team that owns the changed folders. Payments code gets reviewed by the payments team.
  4. CI checks run tests, linting and a commit-message check. A red check blocks the merge.
  5. 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.
  6. 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 on release/2.4, then gets git 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).
Press enter or click to view image in full size

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 callbacks
Bank 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 feature
  • fix: a bug fix
  • docs: documentation only
  • style: formatting, no logic change
  • refactor: same behaviour, new structure
  • perf: a performance improvement
  • test: adding or fixing tests
  • build / ci: build system or pipeline
  • chore: housekeeping, like dependency bumps
  • revert: 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 a Signed-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?

Press enter or click to view image in full size

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, later git 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

  1. Rewrite only your own, unshared commits.
  2. Never force push to main.
  3. Pull with rebase; merge PRs with squash.
  4. Small commits, clear messages.
  5. Scared? git reflog almost 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.

--

--

Sathishkumar Kandaswamy

Written by Sathishkumar Kandaswamy

0 followers

I strive to continually expand my knowledge and actively contribute as a member of the software world, working towards shaping the real world in meaningful ways