← Blog

Git Fundamentals

The commands you use every day, the mental model that makes them make sense, and a clear take on merging, fast-forwards, and cherry-picking—the parts that confuse almost everyone at first.

GitGuide

1 · The mental model

Most Git confusion comes from not having a picture of what it actually stores. Three ideas cover almost everything:

A commit is a snapshot
Each commit records the full state of your tracked files at a moment in time, plus a pointer to its parent commit(s). It has a unique ID (a SHA hash like d41b2d6). Commits are immutable: you never change one, you create new ones.
A branch is just a movable pointer
A branch like main is nothing more than a label pointing at one commit. When you commit, the label moves forward to the new commit. Creating a branch is cheap: it's a 41-byte file with a SHA in it.
HEAD is "where you are"
HEAD points at the branch (and therefore commit) you currently have checked out. "Moving HEAD" means switching what you're looking at.
Why it mattersOnce you see branches as pointers and commits as snapshots, operations like fast-forward, reset, and cherry-pick stop being magic. They're just moving pointers and copying snapshots around.

2 · Setup & a first repo

Set your identity once per machine. It gets stamped on every commit:

# one-time global config
git config --global user.name "Your Name"
git config --global user.email "[email protected]"

Start tracking a project, or grab an existing one:

# turn the current folder into a repo
git init

# or copy a remote repo (and its full history) locally
git clone https://github.com/user/project.git

3 · The everyday cycle

This is 90% of day-to-day Git: change files, stage them, commit them. The staging area (or "index") is a holding zone. It lets you choose exactly which changes go into the next commit.

working tree ──git add──▶ staging area ──git commit──▶ repository (history) (your edits) (next snapshot) (permanent)
# see what's changed and what's staged
git status

# stage specific files (or "." for everything)
git add src/index.js
git add .

# record the staged changes as a commit
git commit -m "Add login form validation"

# review history (compact, one line each)
git log --oneline
Good commitsWrite messages in the imperative, like "Add X" or "Fix Y", as if completing the sentence "This commit will…". Keep each commit focused on one logical change; it makes history readable and reverting painless.

4 · .gitignore

A .gitignore file lists patterns Git should not track: build output, dependencies, secrets, local-only files. It takes effect immediately in the working tree, but must be committed for the rules to apply for teammates too.

# .gitignore: one pattern per line
node_modules/
dist/
*.log
.env
Folder-local ignore trickTo ignore everything inside a folder but keep the folder (and the rule) in version control, put a .gitignore in that folder containing * then !.gitignore: ignore all, except this file.
Already tracked?.gitignore only affects untracked files. If a file is already committed, ignoring it later won't remove it. You must git rm --cached <file> first.

5 · Working with remotes

A remote is a copy of the repo hosted elsewhere (e.g. GitHub), conventionally named origin. Your local repo and the remote are independent. You sync deliberately.

# send your commits to the remote
git push origin main

# first push of a new branch: set the upstream link
git push --set-upstream origin my-branch

# download remote changes AND merge them into your branch
git pull

# download remote changes WITHOUT merging (inspect first)
git fetch
Fetch vs pullfetch updates your knowledge of the remote without changing your working files, and is safe to run anytime. pull is fetch + merge in one step. When in doubt, fetch first and look before you merge.

6 · Branching

Branches let you develop in isolation, then integrate. Because a branch is just a pointer, creating and switching is instant.

# list branches (* marks the current one)
git branch

# create and switch to a new branch (modern syntax)
git switch -c feature/login

# the older, still-common equivalent
git checkout -b feature/login

# switch back
git switch main

# delete a merged branch
git branch -d feature/login
switch vs checkoutgit switch and git restore are newer commands that split the overloaded git checkout into clearer jobs: switch changes branches, restore discards file changes. checkout still does both and you'll see it everywhere.

7 · Merging & fast-forward

Merging brings the commits from one branch into another. How Git does it depends on whether the branches have diverged.

Fast-forward: just move the pointer

If the branch you're merging into hasn't moved since the other branch split off (i.e. its tip is a direct ancestor of the other branch), there's nothing to actually combine. Git just slides the pointer forward. No new commit is created.

before: A───B───C (main) \ D───E (feature) main hasn't moved since B after a fast-forward of main: A───B───C───D───E (main, feature) main just slid forward to E
# fast-forward main to the branch tip, only if possible
git switch main
git merge --ff-only feature

Merge commit: when history has diverged

If both branches have new commits, there's no straight line to slide along. Git performs a three-way merge and records a new merge commit with two parents, tying the histories together.

A───B───C───F (main) main moved on (F) \ \ D───E───M (merge commit, two parents)

The three flavours of git merge

This is the part worth memorising. The flag controls what happens, and crucially what happens when a fast-forward isn't possible:

CommandFast-forward possibleHistories diverged
git merge <branch>Fast-forwards (no merge commit)Creates a merge commit
git merge --ff-only <branch>Fast-forwards (no merge commit)Aborts with an error
git merge --no-ff <branch>Forces a merge commitCreates a merge commit
The point of --ff-onlyIt never creates a merge commit: it either fast-forwards cleanly or refuses. Use it when you expect a clean fast-forward and want Git to shout if your assumption is wrong (e.g. someone else pushed in the meantime), instead of quietly making a merge commit you didn't intend.
--no-ffThe opposite: it always records a merge commit, even when a fast-forward was possible. Teams use it to keep an explicit "this feature branch was merged here" marker in history.

Check before you merge

To know in advance whether a fast-forward is even possible, ask Git whether one branch is an ancestor of the other:

# exit code 0 = yes, main is an ancestor → clean fast-forward is possible
git merge-base --is-ancestor origin/main feature \
  && echo "clean fast-forward" \
  || echo "histories diverged"

8 · Undoing things

Git has a different tool for each kind of "undo". Picking the right one matters, especially the distinction between rewriting history (fine locally, dangerous once pushed) and adding a new commit that reverses an old one (always safe).

Discard uncommitted changes

# throw away unstaged edits to a file
git restore src/index.js

# unstage a file (keep the edits, just remove from staging)
git restore --staged src/index.js

Fix the last commit

# change the message or add forgotten files to the previous commit
git commit --amend

Move the branch pointer back with reset

reset moves your branch to an earlier commit. What it does to your files depends on the mode:

ModeBranch pointerStaging areaWorking files
--softmoved backkeptkept
--mixed (default)moved backresetkept
--hardmoved backresetdiscarded
# undo last commit, keep changes staged (great for re-committing)
git reset --soft HEAD~1

# DESTRUCTIVE: undo last commit and delete the changes
git reset --hard HEAD~1

Revert: undo safely on shared history

If a commit has already been pushed and others may have it, don't rewrite history. Instead, revert creates a new commit that undoes the changes, leaving the original in place.

git revert abc1234
Golden rulereset --hard and --amend rewrite history. That's fine on commits only you have, but rewriting pushed, shared commits forces everyone else into conflicts. On shared branches, prefer revert.

9 · Stashing

A stash shelves your uncommitted changes and hands you back a clean working tree, without making a commit. It's the quickest way to deal with "I'm mid-change but I need to switch branches / pull / look at something else right now." The shelved changes go onto a stack you can reapply later.

Stash and restore

# shelve all tracked changes, leaving a clean working tree
git stash

# label it so you remember what it was later
git stash push -m "half-done login validation"

# include untracked (brand-new) files too
git stash -u

# reapply the most recent stash AND drop it from the stack
git stash pop

# reapply but keep it on the stack (e.g. to reuse on another branch)
git stash apply

Inspecting and cleaning up

# list everything on the stash stack
git stash list

# preview the diff inside a specific stash
git stash show -p stash@0

# delete one stash, or wipe them all
git stash drop stash@0
git stash clear
pop vs applypop applies the stash and removes it in one step; apply leaves it on the stack so the same changes can be reused. When in doubt, apply: if the reapply hits conflicts, your stash is still safely saved.
Untracked filesA plain git stash only shelves tracked changes, so brand-new files are left behind in your working tree and can follow you onto the next branch. Use git stash -u to sweep untracked files in too.

10 · Tagging

A tag is a fixed, human-readable name pinned to one specific commit, like v1.4.0. Where a branch pointer moves forward every time you commit, a tag stays put: it permanently marks a moment in history. That's what makes tags the natural way to mark releases.

Lightweight vs annotated

Git has two kinds of tag. Use annotated tags for anything you'll share or deploy: they carry who, when, and why.

LightweightAnnotated
Storesjust a pointer to a committagger, date, message, optional GPG signature
Create withgit tag v1.0.0git tag -a v1.0.0 -m "…"
Best forquick, private, temporary marksreleases and anything you push
# annotated tag on the current commit (recommended for releases)
git tag -a v1.2.0 -m "Release 1.2.0"

# lightweight tag: just a name on the current commit
git tag v1.2.0

# tag an older commit by its SHA
git tag -a v1.1.0 abc1234 -m "Release 1.1.0"

# list tags (optionally filtered)
git tag
git tag -l "v1.*"

Tags aren't pushed automatically

A plain git push sends commits, not tags. You push tags explicitly:

# push one tag
git push origin v1.2.0

# push every local tag at once
git push origin --tags

Deploying & rolling back by tag

This is where tags earn their keep for deployment. Tag every release, then on your production box (a VPS or cloud container holding a clone of the repo), check out the exact tag you want to run. Because a tag never moves, the code in production is pinned to an exact, named commit, and a rollback is just checking out the previous tag.

# --- on each release, from your machine ---
git tag -a v1.3.0 -m "Release 1.3.0"
git push origin v1.3.0

# --- on the production container ---
git fetch --tags        # pull down the new tags
git checkout v1.3.0     # deploy exactly this release

# something broke? roll back instantly to the previous tag
git checkout v1.2.0
Deploy tags, not branchesA branch like main keeps moving, so "deploy main" means something different every day. A tag is immutable. A deployed tag is reproducible, and rollback is deterministic: you always know exactly which commit is live, and exactly which one you're going back to.
Detached HEAD is fine hereChecking out a tag lands you in detached HEAD, sitting on a specific commit rather than a branch. That's a problem if you want to commit, but for deployment it's exactly right: the container only ever runs the code, it doesn't develop on it.

11 · Cherry-pick

Cherry-pick copies the change introduced by a specific commit and replays it onto your current branch as a new commit with a new SHA. Useful for grabbing one fix out of a branch without merging the whole thing.

# apply commit abc1234 onto the current branch
git cherry-pick abc1234

# a range of commits
git cherry-pick abc1234..def5678
Watch outBecause cherry-pick creates new commits, the same change ends up with different SHAs on different branches, which can cause duplicate-looking history if you later merge those branches. When a whole branch should go in and a fast-forward is available, merge (or fast-forward) instead of cherry-picking; reserve cherry-pick for "I want just this one commit."

12 · Advanced: rebasing

Rebasing replays your commits, one by one, on top of a different base commit. Where a merge ties two histories together with a merge commit, a rebase rewrites your commits so they sit in a clean straight line, as if you'd branched from the latest code all along.

before: A───B───C (main) \ D───E (feature) after git rebase main (while on feature): A───B───C (main) \ D'──E' (feature) D and E replayed onto C as NEW commits

Rebase to stay linear when divergence is small

When main has moved only a little and your branch has just a few commits, rebasing onto it keeps history tidy and avoids a merge commit. Run it from the feature branch:

git switch feature
git rebase main

# hit a conflict? fix the files, stage them, then carry on
git add <resolved-files>
git rebase --continue

# or bail out completely and return to where you started
git rebase --abort
Rebase vs mergeBoth integrate changes. Rebase gives a clean, linear history (no merge commits) but rewrites commits; merge preserves exactly what happened and is non-destructive. Rule of thumb: rebase to tidy your own in-progress work, merge to combine shared branches.

Interactive rebase: clean up messy commits

This is the everyday workhorse: collapsing a trail of wip, fix typo, actually fix it commits into one clean commit before sharing. Rewind a number of commits and edit the to-do list Git opens:

# rework the last 3 commits
git rebase -i HEAD~3

Git opens an editor listing those commits oldest-first, each prefixed with an action. Change the action, save and close, and Git replays accordingly:

ActionWhat it does
pickKeep the commit as-is
rewordKeep the commit, edit its message
editPause on the commit so you can amend its contents
squashFold into the previous commit, combining both messages
fixupLike squash, but discard this commit's message
dropDelete the commit entirely

To squash three commits into one tidy commit, keep the first as pick and fold the rest in: squash to merge their messages, fixup to throw the noise away:

pick   a1b2c3  Add login form
fixup  d4e5f6  wip
fixup  7g8h9i  fix typo
# result: one clean "Add login form" commit
Golden rule (again)Rebasing rewrites history: replayed commits get brand-new SHAs. Only rebase commits that live solely on your machine. Rebasing commits already pushed to a branch others share forces everyone into conflicts. Clean up locally first, then push.
Already pushed your own branch?If you've rebased a feature branch that only you use but already pushed, you'll need to overwrite the remote. Use git push --force-with-lease: it refuses if someone else pushed in the meantime, making it far safer than a plain --force.

13 · Cheat sheet

TaskCommand
Status of working treegit status
Stage changesgit add .
Commitgit commit -m "msg"
Compact historygit log --oneline
Pushgit push origin main
Update from remote (no merge)git fetch
New branch + switchgit switch -c name
Merge, fail if not fast-forwardgit merge --ff-only branch
Is FF possible?git merge-base --is-ancestor a b
Copy one commit overgit cherry-pick <sha>
Replay commits onto a new basegit rebase main
Squash / clean up last N commitsgit rebase -i HEAD~N
Tag a release (annotated)git tag -a v1.0.0 -m "msg"
Push tags to the remotegit push origin --tags
Deploy / roll back to a taggit checkout v1.0.0
Safely overwrite your pushed branchgit push --force-with-lease
Unstage a filegit restore --staged <file>
Undo last commit, keep workgit reset --soft HEAD~1
Safely undo a pushed commitgit revert <sha>
Shelve uncommitted changesgit stash (-u for untracked)
Reapply & drop latest stashgit stash pop

Glossary

SHA / hash
The unique ID of a commit, e.g. d41b2d6. Usually shortened to the first 7 characters.
HEAD
A pointer to the commit (via branch) you currently have checked out. HEAD~1 means "one commit before HEAD".
Ancestor
A commit reachable by following parent links backward. Fast-forward is possible only when the target's tip is an ancestor of the source.
Upstream
The remote branch your local branch tracks (e.g. origin/main), so plain git push/pull know where to go.
Staging area / index
The holding zone between your edits and a commit. You stage exactly what the next commit should contain.
Fast-forward
Advancing a branch pointer along a straight line of commits, with no merge commit needed.
Tag
A fixed, named pointer to a specific commit (e.g. v1.4.0) that, unlike a branch, never moves. Used to mark releases.
Stash
A stack of shelved, uncommitted changes saved off to the side so the working tree is clean. Reapply with git stash pop (removes it) or apply (keeps it).
Detached HEAD
The state of having a specific commit checked out directly (e.g. after git checkout v1.0.0) rather than a branch. Fine for running/inspecting code; new commits made here belong to no branch until you create one.