Git Workflow: Understanding the Git Commands That Look Almost the Same
Without Git & GitHub, a day in software development is really hard.
Git is very easy to start using in a project, but it may become unclear when it comes to concepts like HEAD, Commit, Push, Pull, Rebase, Merge, Soft Reset, and Hard Reset. All of them look pretty similar, but it makes me think:
Why are there so many commands if some of them look similar? Do they actually do the same thing?
Or, more importantly,
What exactly is Git doing when I run these commands? Let’s begin with some generalization before going into the main differences.
What is Git?
The common but important question here:
What is Git?
Git is a version control system that tracks changes in files inside a repository.
We can initialize Git in a directory using the CLI:
git initAfter that, Git starts tracking changes in that repository. Even a simple change in a file can be detected. Sounds interesting, right? But most of us don’t think deeply about how Git actually works. Does it do some kind of magic?
For example, imagine I have a file: app.js
Then I change one line.
Does Git make another complete copy of app.js?
Then I change another line.
Does Git make another complete copy again?
We work with Git every day, and sometimes our repositories contain thousands of files. If Git really kept a complete copy of every file after every change, wouldn’t that consume a huge amount of storage?
So where does Git actually keep all these changes?
Another Git magic? We can explore instead of just overthinking.
A General Idea of How Git Works
Before talking about add, commit, stash, reset, revert, merge, or rebase. I think we should understand one simple idea.
Git mainly works with a repository and a set of objects that represent the history of that repository. When we initialize a repository by git init; Git creates a hidden .git directory.
So our project can look something like:
my-project/
│
├── src/
├── .gitignore
├── README.md
│
└── .git/The .git directory is basically where Git keeps its internal information. But Git does not simply say:
“Here is a copy of your entire project. Let’s copy it again every time something changes.”
Git uses objects and references to represent the state and history of the repository. This is one reason Git can work efficiently with large project histories. And now the interesting part begins.
Because when we work with Git, we are actually moving changes between different states.
A very simple way to imagine the workflow is:
This is where many Git commands start to look confusing. So let’s compare them one by one.
1. git add vs git commit vs git stash
These three commands are commonly used while working on changes. But they do completely different things. Let’s say I modify:
src/user.jsNow Git can see that something has changed.
If I run:
git statusGit may show:
modified: src/user.jsNow what happens when I use git add?
git add src/user.jsThe file moves into the staging area.
Did I save the changes permanently in Git history? The Answer is No.
git add Basically tells Git:
“I want these changes to be part of my next commit.”
So we can think:
Now comes: git commit
For example:
git commit -m "Update user validation"A commit records the staged changes as a new point in the repository history. So:
Then what is git stash?
This is where it becomes interesting.
Suppose I am working on a feature. I changed several files, but suddenly my manager says:
“Can you quickly fix this production issue?”
My current changes are not ready to commit. I don’t want to commit unfinished code just to switch branches. So I can use:
git stashGit temporarily stores my uncommitted changes and gives me a clean working directory.
Later I can bring them back:
git stash popSo these commands have different purposes:git add→ Prepare changes for the next commit
git commit→ Record staged changes in repository history
git stash→ Temporarily put unfinished changes aside
They may all appear during the same workflow, but they solve completely different problems.
2. git status vs git diff
Now we have another common confusion. I changed some files. I want to know:
What is happening in my repository?
The first command I usually think about is:git status gives me an overview. For example:
git status
~$ git status
modified: src/user.js
modified: src/auth.js
untracked: test.jsIt tells me the state of my working tree and staging area. But sometimes I don’t want to know only which files changed. I want to know:
What exactly did I change?
That’s where it git diffbecomes useful. For example:
~$ git diff
- const user = getUser();
+ const user = await getUser();There is also an important detail. If I already staged a change:
git add src/user.jsthen git diff does not normally show that staged change. To see the difference between the staged version and the last commit, I can use:
git diff --stagedSo these commands' purposes:git status→ Overview of repository state
git diff→ Unstaged changes
git diff --staged→ Staged changes
Again, they look similar because both help me inspect changes. But they answer different questions.
3. git fetch vs git pull
Now let’s move from local work to remote repositories. Most of us use GitHub as a remote repository. Suppose another developer pushed some new code to GitHub. My local branch doesn’t know about those changes yet. So I can run: git fetch
What does it do?
It downloads the latest information from the remote repository, but it does not automatically change my current working branch. git fetch is more like:
“GitHub has some new information. Bring that information to me, but don’t change my current work yet.”
Then Comes: git pullThis is where things become different. A pull generally performs two steps:
git fetch
+
integrate the fetched changesDepending on configuration, that integration may happen through a merge or rebase. So a simplified view is:
git fetch
→ Download remote changes
→ Don't integrate them into my current branch automatically
git pull
→ Fetch remote changes
→ Integrate them into my current branchThis difference becomes very useful when I want to inspect remote changes before deciding what to do with my local branch. For the case I use:
~$ git fetch originThen I can inspect the remote branch before integrating it. Instead of blindly saying git pull every time.
4. git reset vs git revert
Before talking about git reset and git revert, I think we need to understand one more thing.
What exactly is HEAD in Git?
We have already talked about commits and branches, but there is something that keeps appearing in Git commands: HEAD
At first, I also thought: Is HEAD another commit?
Or maybe it is the latest commit? Not exactly.
Let’s take a simple history:
A -> B -> CSuppose I am currently working on the main branch. We can imagine it like this:
We can imagine it like this:
main
↓
CAnd HEAD tells Git where I am currently positioned. A simplified way to visualize it is:
HEAD usually points to the current branch, and the branch points to the current commit.Now suppose I create another commit:
git commit -m "Add authentication"And now:
The important thing here is that HEAD moves as I move through the repository history. If I switch to another branch: git switch feature
HEAD moves to that branchSo I don’t think about HEAD as:
“The latest commit.”
Instead, I think about it as:
“Where am I currently positioned in Git?”
And once we understand that, git reset becomes much easier to understand.
Now, what does git reset actually do?
Suppose I realize:
“Wait, commit C was a mistake. I want my branch to go back to B.”
Get Naimur Rahman’s stories in your inbox
Join Medium for free to get updates from this writer.
I can run:
~$ git reset B
Or, more commonly:
~$ git reset HEAD~1Because HEAD~1 means the commit immediately before the current HEAD.
So what actually happened? The branch reference moved from C back to B. This is an important point. git reset is not simply:
"Delete a commit."
It primarily moves the current branch reference to another commit, while the effect on your working directory and staging area depends on the reset mode.
And this is where --soft, --mixed, and --hard come into the story.
git reset --soft
Let’s start with:git reset --soft HEAD~1
But the changes introduced by C are still staged.
So Git is basically saying:
“Okay, let’s move the branch back, but keep the changes ready for another commit.”
I can think about it like:
HEAD moves
↓
Commit disappears from current branch position
↓
Changes remain stagedThis can be useful when I realize:
“My last commit message was wrong, or I want to modify the last commit before committing again.”
git reset --mixed
Now Let’s try:git reset --mixed HEAD~1.
This is actually the default behavior of git reset when no mode is specified.
But this time, the changes from C are no longer staged. They remain in my working directory as unstaged changes. So:
HEAD moves
↓
Changes are kept
↓
Changes become unstagedNow I can modify those changes, stage only what I want, and create a new commit.
git reset --hard
And now comes the dangerous one: git reset --hard HEAD~1
But this time, Git also makes the working directory and staging area match the target commit. So the changes introduced by C are discarded from my current working tree.
That means:
--soft
→ Move HEAD
→ Keep changes staged
--mixed
→ Move HEAD
→ Keep changes
→ Unstage them
--hard
→ Move HEAD
→ Discard changes from the working tree and staging areaThis is why git reset --hard should be used carefully. If I have local changes that I have not committed or otherwise saved, I may lose them.
So now we can understand reset much better. But there is another question.
What if the commit has already been pushed to GitHub?
Should I still reset it? This is where it git revert becomes important.
git reset vs git revert
Suppose the history is: A → B → C
And C introduced a bug. If C only exists in my local branch, I might decide to reset: git reset --hard B
If C only exists in my local branch, I might decide to reset:
But imagine C has already been pushed to GitHub and other developers may already have based their work on it. Now changing the existing branch history can create problems for everyone else. Instead, I can use: git revert c
Git creates a new commit that reverses the changes introduced by C.
Where D is the new commit that undoes the changes from C. The original commit C is still visible in the history. This is the important difference:
git reset
→ Move the branch to another point in history
git revert
→ Create a new commit that undoes an earlier commitSo if I think about it from a history perspective:
And now the difference becomes much clearer. reset changes where the branch points. revert adds another commit to the history.
So when should I use which one?
From my understanding, a simple rule can be:
Git Reset: When I need to move my branch backward and control what happens to the changes.
Git Revert: When I want to undo a change without rewriting the existing shared history.
Of course, Git situations can be more complicated than this simple rule, but this gives me a useful mental model. And now I finally understand why HEAD matters here. Because when I run:
git reset HEAD~1I am not just telling Git:
“Delete my last commit.”
I am telling Git:
“Move my current branch reference away from where
HEADcurrently is, to its previous commit."
And whether the changes remain staged, unstaged, or are discarded depends on whether I use:
--soft
--mixed
--hardThat makes the whole thing much less mysterious. Git is not doing magic. It is moving references, changing states, and recording history.
5. git cherry-pick vs git merge vs git rebase
Now we understand commits, branches, HEAD, reset, and revert. But there is another situation that happens almost every day when working with a team.
Multiple branches have different histories.
At some point, I need the changes from my feature branch to come back into main. And this is where three Git commands start looking very similar:
git merge
git rebase
git cherry-pickAll of them can somehow bring changes from one place to another. But again, they don’t actually do the same thing. Let’s understand them one by one.
git merge
Let’s start with the most straightforward one. Suppose my history looks like:
And I want to bring the feature branch into main.I can run:
git merge featureGit combines the histories of the two branches. Depending on the situation, Git may be able to perform a fast-forward, or it may create a merge commit. For example, with a merge commit:
What I like about merge is that it keeps the branch history visible. I can look at the history and understand:
“These commits were developed on another branch, and then that branch was merged into main.”
So I think about merge like this:
git merge
→ Combine two lines of historyIt doesn’t take my commits and pretend they were created somewhere else. It connects the histories.
git rebase
Now let’s look at rebase. Suppose I have the same branch state: My feature branch was created from C. But main has now moved forward to F.
I want my feature branch to be based on the latest main. I can use:
git switch feature
git rebase mainGit takes my feature commits and reapplies them on top of the current main. So the history can become:
Notice something interesting here. The original D and E are not simply moved. Git creates new commits containing the changes, so they get new commit identities.
The changes may be similar, but they are new commits. And now the history looks much more linear. There is no merge commit in this example. This can make the project history easier to read. But there is an important rule to understand.
“Rebase rewrites commit history.”
So if other developers are already working with the commits I am rebasing, rewriting those commits can create problems for them. That’s why rebasing is generally safer for my own unpublished feature work than for shared history. So:
git rebase
→ Reapply commits on top of another base
→ Creates new commit identities
→ Can produce a cleaner, linear historyBut then, what is git cherry-pick?
Now let’s imagine something slightly different. Suppose my feature branch has:
But I don’t want the entire feature branch. I only want the authentication fix. So merging the entire branch doesn’t make sense. Rebasing the branch doesn’t make sense either.
I only want one specific commit. That’s where: git cherry-pick becomes useful. For example:
git cherry-pick <commit-id>Git takes the changes introduced by that particular commit and applies them to my current branch. Suppose my main branch is:
Again, notice the difference. The changes from F were applied, but Git creates a new commit on the current branch. So F have different commit identities.
This is why cherry-pick is useful when I need a specific change rather than an entire branch. For example, maybe a bug was fixed in a development branch, but I need that same fix in a release branch. I don’t necessarily want all the unfinished development work. I only want that particular fix.
git cherry-pick
→ Take a specific commit
→ Apply its changes to the current branch
→ Create a new commitNow, how are these three actually different?
At this point, all three commands may still sound similar. They all somehow bring changes from somewhere else. But the intention behind them is different.
I think about them like this:git merge
→ “I want to combine these branch histories.”
git rebase
→ “I want to replay these commits on a different base.”
git cherry-pick
→ “I only want this particular commit.”
And now I know why Git needs all three. They may produce similar results in some situations, but they communicate different intentions and affect history differently.
Conclusion
After going through all these commands, Git may still look like a lot of things to remember. But I don’t think we need to remember everything as a separate command. The important thing is to understand what we are trying to do.
If I change something, I can use git status and git diff to understand my changes.
If I am ready to save those changes into history, I use git add and git commit.
If my work is not ready but I need a clean working directory, I can use git stash.
If someone else has pushed changes, I can use git fetch to see them first, or git pull to fetch and integrate them.
If I made a mistake in my local history, git reset can move my branch back.
If that change is already shared with others, git revert can undo it by creating a new commit instead of rewriting the existing history.
And when different branches need to come together, I have merge, rebase, and cherry-pick, depending on what I actually want to bring into my branch.
So, instead of remembering Git like this:
add
commit
stash
fetch
pull
reset
revert
merge
rebase
cherry-pickI prefer to remember the workflow:
Change
↓
Inspect
↓
Stage
↓
Commit
↓
Share
↓
Synchronize
↓
Combine or Undo when neededAnd there is one thing I learned from exploring these commands:
Git is not difficult because there are too many commands.
It feels difficult when we don’t understand what each command is changing.
Once we understand the relationship between the working directory, staging area, commits, branches, HEADand remote repository, the commands start to make much more sense.
We don’t need to memorize every command before using Git. We just need to ask one simple question before running one:
“What do I want to happen to my code and its history?”
I think that question is enough to make Git feel a lot less like magic.
Thank you for reading this article. This article reflects my understanding based on learning and working with Git. If you find any inaccuracies or have additional insights, I’d love to hear your feedback. Feel free to leave a comment or ask any questions.







