You use Git every single day. Commit, push, pull. It works until it doesn’t. And then you’re googling how to undo a git rebase at 11 p.m., mass-copying commands from Stack Overflow, praying you don’t make the situation worse.
An uncomfortable truth is that many experienced developers don’t truly understand Git. They memorize commands, but they don’t know what’s happening underneath. In the next few minutes, let’s change that. Let’s understand Git better and never fear it again.
Back to Basics: The Commit
Let’s start from zero. Forget everything you think you know. Git is a database, and the fundamental unit of that database is the commit.
So, what is a commit? It’s a snapshot. Think of it as a complete photograph of your entire project at one moment in time. It’s not just the changes you made; it’s the entire state of every single file.
Each commit contains three essential things:
- A pointer to the snapshot: This points to the complete state of every file exactly as it existed at that moment.
- Metadata: This includes who created the commit, when they created it, and the all-important commit message.
- A pointer to the parent commit: This is a reference to the commit that came directly before it.
When you make a new commit, Git saves the full state of your project and links it back to where you were. This creates a chain. Each commit points to its parent, and those parents point to their parents, all the way back to the very first commit. That initial commit is special; it has no parent and serves as the origin point of your project’s history.
Later, when you merge branches, you’ll create commits with two parents, but for now, remember this crucial rule: commits point backwards. Always backwards. Children know their parents, but parents never know their future children.
The Project History as a Graph
If everyone just committed one after another, you’d have a simple, straight-line history. But real development is messier. You branch off for a new feature. A colleague branches to fix a bug. Suddenly, you have commits sharing the same parent but heading in different directions. Then you merge, creating a commit with two parents.
This structure has a name: a Directed Acyclic Graph (DAG). It sounds intimidating, but it’s not. Think of it like a family tree.
- Directed means relationships only go one way. Children point to parents, never the reverse.
- Acyclic means there are no loops. You can’t be your own grandparent; you can’t create a cycle in history.
- Graph simply means it’s a collection of nodes (commits) and connections (the links between them).
This graph is your project’s history. Every branch, every merge, every decision any developer ever made is captured in this structure. Here’s what makes Git so powerful: because every commit is a complete snapshot, you can jump to any point in this graph and see your project exactly as it existed. No reconstruction, no replaying changes. It’s just there.
Branches are Just Sticky Notes
Now, this graph can get complex. How do we navigate it? How does Git know which commits matter? That’s where branches come in, and they are far simpler than you’d believe.
When learning Git, people often assume branches are heavy, complex things—a whole separate copy of the codebase. This is completely wrong.
A branch is just a sticky note. It’s a pointer, a tiny text file that contains one single piece of information: the hash of a commit. That’s it. When you create a new branch called feature-login, Git creates a small file that says this branch points to commit A1B23C. Nothing more.
Branches are just labels stuck on different commits. The commits themselves have no idea what branches exist. Branches don’t contain commits; they point at them. When you make a new commit while on a branch, Git performs two simple steps:
- It creates the new commit, pointing back to where you were.
- It moves the sticky note (the branch pointer) forward to the new commit.
That’s branching. The entire concept. This is why creating a branch is instantaneous. You’re not copying anything; you’re just placing a sticky note. And what about main? It’s not special. It’s just another sticky note that, by convention, we’ve agreed is the primary line of work.
Where Are You? Meet HEAD
So we have commits, and we have branches pointing at commits. But how does Git know where you are and what you’re working on? Meet HEAD.
HEAD is Git’s way of tracking your current location. It’s another pointer, but usually, instead of pointing directly at a commit, it points at a branch. When you’re on the main branch, HEAD points to main, and main points to a commit. That’s your current location.
Run git checkout feature, and HEAD moves to point at the feature branch. You’re now working on that line of history.
But here’s a situation that confuses people: the detached HEAD state. What if you check out a specific commit hash, not a branch? Now, HEAD points directly to that commit, with no branch in between. This isn’t as scary as it sounds if you understand what’s happening. You can still work and make commits, but no branch is following along. When you switch away, those new commits are orphaned—floating in space with no branch pointing to them. Eventually, Git’s garbage collection will clean them up.
Imagine a developer checks out an old commit to test something, finds a bug, fixes it, and commits the fix right there. Then they run git checkout main to merge their work. The fix vanishes. It was never on a branch. It was orphaned, then garbage collected. Hours of work, gone. This is why Git warns you about a detached HEAD—not because your repository is broken, but because anything you commit won’t be saved unless you create a new branch to hold it.
The Three Arenas: Working Directory, Staging Area, and Repository
Before we talk about undoing things, we need to understand one more core concept. Git has three areas where your code can live:
- The Working Directory: The actual files on your disk, what you see in your code editor.
- The Staging Area (or Index): A waiting room where you prepare what will go into your next commit.
- The Repository: The
.gitdirectory, the database of commits, the permanent history.
When you edit a file, it changes in your working directory. When you run git add, you move a copy of those changes to the staging area. When you run git commit, Git takes everything in the staging area and creates a new, permanent commit in the repository. Understanding these three layers is the key to mastering commands that undo changes.
Undoing Mistakes: Checkout, Reset, and Revert
checkout, reset, and revert all seem to undo things, but they perform completely different operations. Confusing them can cost you work.
-
git checkout: This command’s main job is to move HEAD. Runninggit checkout mainpoints HEAD to themainbranch. Runninggit checkout C1points HEAD directly to that commit (a detached state). Your working directory updates to match that commit’s snapshot. Importantly, no commits are changed, and no branches are moved. History is untouched. You’re just looking around. It’s safe and non-destructive. git reset: This command is more dangerous; it moves a branch pointer. When you’re onmainand rungit reset C1, you are telling Git to move themainbranch to point at commitC1. Any commits that came afterC1still exist in the database for a while, but they are now orphaned.resethas three modes:--soft: Moves the branch pointer but leaves your staging area and working directory unchanged. The changes from the “undone” commits will appear as staged, ready to be committed again. Useful for squashing multiple commits into one.--mixed(the default): Moves the branch pointer and resets the staging area to match the target commit. Your changes still exist in your working directory files but are unstaged.--hard: Moves the branch pointer and resets both the staging area and the working directory. Your files on disk are changed. Uncommitted work is gone. Be very careful with this command, as it can lead to permanent data loss for uncommitted changes.
git revert: This command takes a completely different philosophy. It doesn’t move or abandon anything. Instead,revertcreates a new commit that does the exact opposite of an old commit. If commitCadded 50 lines,git revert Ccreates a new commitDthat removes those same 50 lines. The original history is preserved. You’ve simply recorded a new decision: “we decided to undo what we did earlier.” This is the safe way to undo something that has already been pushed and shared with others.
The Power and Peril of Rebase
Finally, the command that confuses most developers: rebase.
Let’s set the scene. You created a feature branch from main and made a few commits. Meanwhile, your colleagues have updated main with their own commits. You have two options to integrate their changes:
- Merge: This creates a new “merge commit” with two parents, showing the true, parallel history of the work.
- Rebase: This takes your commits and replays them, one by one, on top of the latest version of
main.
You must understand this: a commit’s identity is its hash, which is generated from its content, metadata, and parent pointer. Change any of those, and you get a completely different commit. Git can’t “move” commits. So, rebase works by creating new commits. It looks at your first commit, calculates the changes, and creates a new commit with those same changes but with the latest main as its parent. It repeats this for all your commits, creating a clean, linear history.
The old commits are orphaned and will eventually be garbage collected. This is why you never rebase commits that others have seen or pulled. If a colleague has the old commits and you push the newly rebased ones, Git sees them as completely unrelated work, leading to a nightmare of duplicate changes and conflicts. On local branches you haven’t shared, however, rebase is a powerful tool for keeping history linear and clean.
Your Safety Net: The Reflog
You’ve made a mistake. You ran git reset --hard by accident. You rebased incorrectly. Your commits are gone. What do you do?
Run git reflog.
The reflog is a log of everywhere HEAD has pointed recently. Every checkout, every commit, every reset. Those “lost” commits from your reset or the old commits from before your rebase are probably still here. Find the hash in the reflog, create a branch pointing to it (git branch recovered-work <hash>), and you’ve recovered your work.
Git almost never truly deletes anything immediately; it just hides it. The reflog is your map to finding it.
Conclusion
Let’s step back. Git is a database of snapshots. Commits point to parents, forming a graph. Branches and HEAD are just pointers telling Git what matters and where you are.
checkoutmoves your view.resetmoves branches.revertadds corrective history.rebasereplays commits with new parents.- And when everything goes wrong,
reflogis your safety net.
The next time something breaks, you won’t be copying Stack Overflow commands and praying. You’ll be thinking in graphs and pointers. You’ll know exactly what happened and exactly how to fix it.