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.
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
mainis 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"
HEADpoints at the branch (and therefore commit) you currently have checked out. "Moving HEAD" means switching what you're looking at.
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.git3 · 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.
# 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 --oneline4 · .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.gitignore in that folder containing * then !.gitignore: ignore all, except this file..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 fetchfetch 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/logingit 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.
# fast-forward main to the branch tip, only if possible
git switch main
git merge --ff-only featureMerge 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.
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:
| Command | Fast-forward possible | Histories 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 commit | Creates a merge commit |
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.jsFix the last commit
# change the message or add forgotten files to the previous commit
git commit --amendMove the branch pointer back with reset
reset moves your branch to an earlier commit. What it does to your files depends on the mode:
| Mode | Branch pointer | Staging area | Working files |
|---|---|---|---|
--soft | moved back | kept | kept |
--mixed (default) | moved back | reset | kept |
--hard | moved back | reset | discarded |
# 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~1Revert: 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 abc1234reset --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 applyInspecting 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 clearpop 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.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.
| Lightweight | Annotated | |
|---|---|---|
| Stores | just a pointer to a commit | tagger, date, message, optional GPG signature |
| Create with | git tag v1.0.0 | git tag -a v1.0.0 -m "…" |
| Best for | quick, private, temporary marks | releases 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 --tagsDeploying & 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.0main 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.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..def567812 · 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.
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 --abortInteractive 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~3Git opens an editor listing those commits oldest-first, each prefixed with an action. Change the action, save and close, and Git replays accordingly:
| Action | What it does |
|---|---|
pick | Keep the commit as-is |
reword | Keep the commit, edit its message |
edit | Pause on the commit so you can amend its contents |
squash | Fold into the previous commit, combining both messages |
fixup | Like squash, but discard this commit's message |
drop | Delete 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" commitgit push --force-with-lease: it refuses if someone else pushed in the meantime, making it far safer than a plain --force.13 · Cheat sheet
| Task | Command |
|---|---|
| Status of working tree | git status |
| Stage changes | git add . |
| Commit | git commit -m "msg" |
| Compact history | git log --oneline |
| Push | git push origin main |
| Update from remote (no merge) | git fetch |
| New branch + switch | git switch -c name |
| Merge, fail if not fast-forward | git merge --ff-only branch |
| Is FF possible? | git merge-base --is-ancestor a b |
| Copy one commit over | git cherry-pick <sha> |
| Replay commits onto a new base | git rebase main |
| Squash / clean up last N commits | git rebase -i HEAD~N |
| Tag a release (annotated) | git tag -a v1.0.0 -m "msg" |
| Push tags to the remote | git push origin --tags |
| Deploy / roll back to a tag | git checkout v1.0.0 |
| Safely overwrite your pushed branch | git push --force-with-lease |
| Unstage a file | git restore --staged <file> |
| Undo last commit, keep work | git reset --soft HEAD~1 |
| Safely undo a pushed commit | git revert <sha> |
| Shelve uncommitted changes | git stash (-u for untracked) |
| Reapply & drop latest stash | git 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~1means "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 plaingit push/pullknow 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) orapply(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.