Concept
The Git object model
Everything in Git is an object identified by a SHA-1 (soon SHA-256) hash:
blob, file content (just bytes, no filename)
tree, directory listing (filenames + blob/tree references)
commit, snapshot pointer: tree + parent commit(s) + author + message
tag, annotated reference to a commitA commit doesn't store diffs, it stores a complete snapshot as a tree of blobs. Diffs are computed on the fly by comparing two trees. This is why git log -p on a huge repo is cheap: it compares parent and child tree objects.
commit c3d4e5f
├─ tree a1b2c3
│ ├─ blob d4e5f6 (src/index.ts)
│ ├─ blob e5f6a7 (src/utils.ts)
│ └─ tree f6a7b8 (src/components/)
│ └─ blob a7b8c9 (Button.tsx)
└─ parent: b2c3d4eGit's efficiency: if src/utils.ts hasn't changed between two commits, both commits reference the same blob e5f6a7. No duplication.
Refs: branches and HEAD
A branch is just a file containing one SHA, the commit it points to. main = .git/refs/heads/main = c3d4e5f. When you commit, the branch ref updates to the new commit. Branches are cheap (a 41-byte file).
HEAD is a pointer to the currently checked-out branch (or a commit directly, "detached HEAD"). When you commit, HEAD → branch ref → new commit SHA.
HEAD → main → c3d4e5f
↓
parent: b2c3d4eMerge vs Rebase
The most important and most misunderstood Git operation pair.
Merge
Creates a merge commit with two parents. Preserves the true history of when branches diverged and converged.
Before: After merge:
A - B - C (main) A - B - C - M (main)
\ \ /
D - E (feat) D - EWhen to merge:
- Public/shared branches (don't rewrite history others have pulled)
- When you want to preserve the exact history of when work was done
- Merging feature branches into
mainvia PRs
Rebase
Re-applies your commits on top of the target branch. The commits get new SHAs (content may be the same, but parent differs). Creates a linear history, easier to read, easier to bisect.
Before: After `git rebase main` from feat:
A - B - C (main) A - B - C (main)
\ \
D - E (feat) D' - E' (feat)D' and E' are new commits with the same changes but different parents. The original D and E are abandoned (garbage collected).
When to rebase:
- Updating a local feature branch with upstream changes before a PR (instead of a merge commit)
- Interactive rebase (
git rebase -i) to clean up messy local commits before review - Never on shared branches, rewriting history that others have pulled forces force-pushes and breaks their local copies
Golden rule: Never rebase commits that have been pushed to a branch others are working from.
Merge vs rebase, practical decision
| Situation | Use |
|---|---|
| Updating feature branch with latest main | git rebase main |
| Merging PR into main | Merge (or squash merge) |
| Cleaning up local WIP commits | git rebase -i HEAD~N |
| Public hotfix branch | Merge |
| Syncing a fork | git rebase upstream/main |
Interactive rebase, the most useful command you're not using
git rebase -i HEAD~5 opens an editor with the last 5 commits:
pick a1b2c3 Fix typo in header
pick d4e5f6 Add user auth
pick e5f6a7 WIP
pick f6a7b8 More auth stuff
pick a7b8c9 Tests for auth
# Commands:
# p, pick = use commit as-is
# r, reword = use commit, but edit message
# e, edit = use commit, but pause to amend
# s, squash = meld into previous commit
# f, fixup = like squash but discard this commit's message
# d, drop = remove commit entirelyCommon pattern: squash WIP commits before a PR, reword confusing messages.
Conflict resolution
A merge/rebase conflict means two branches changed the same lines differently. Git marks the conflict:
<<<<<<< HEAD (your branch)
function greet(name) {
return `Hello, ${name}!`;
=======
function greet(user) {
return `Hi, ${user.name}!`;
>>>>>>> feature/greet-refactorResolution: edit the file to the correct state (pick one side, combine, or rewrite), then git add <file> and git merge --continue (or git rebase --continue).
Strategies for avoiding conflicts:
- Keep feature branches short-lived (< 1 week)
- Sync with
mainfrequently (daily rebase) - Avoid large refactoring PRs that touch many files
Branching strategies
GitHub Flow (most teams)
main (always deployable)
└─ feature/add-auth → PR → review → squash merge → delete branchSimple. Requires CI on every PR. Works well for web services with continuous deployment.
Git Flow (complex release cycles)
main (production tags only)
develop (integration)
└─ feature/* → develop → release/x.x → main + develop
hotfix/* → main + developOverkill for most web apps. Still used in mobile/native release pipelines.
Trunk-Based Development (large teams/monorepos)
Everyone merges to main daily. Feature flags gate incomplete features. Requires extremely fast CI and strong code review culture. Used by Google, Meta.
Common Mistakes
1. Force-pushing to a shared branch without --force-with-lease
git push --force will silently overwrite commits others pushed since your last fetch. git push --force-with-lease fails if the remote has changes you haven't fetched, much safer.
2. Giant commits
A 4,000-line commit is impossible to review and impossible to bisect. Make commits atomic: one logical change per commit. It's easier to squash later than to split.
3. Committing secrets
A .env file committed to a public repo exposes credentials even if you delete it in the next commit, the blob still exists in history. Fix: git filter-repo (preferred) or BFG Repo Cleaner. And immediately rotate the exposed credentials.
4. Rebasing shared branches
Rebasing main or any branch others have checked out rewrites SHAs. Their local history diverges. They see "your branch and origin/main have diverged." Painful to untangle.
5. Not using .gitignore properly
Tracking node_modules, build artifacts, .DS_Store, or .env files pollutes history. Set up global gitignore (~/.gitignore_global) for OS/editor artifacts and per-repo .gitignore for project artifacts.
Best Practices
- Commit messages:
type(scope): short description(Conventional Commits), enables changelog generation and semantic versioning automation. First line ≤ 72 chars. - One commit = one logical change. Future you will thank you at
git bisect. - Use
git stash+git stash popfor WIP when switching contexts. Or even better:git worktree addfor parallel branches without stashing. - Review your diff before committing:
git diff --stagedshows exactly what's going in. Nevergit add .blindly. - Tag releases: . Tags are permanent checkpoints that survive branch deletion.
Performance Tips
git fetchis safe,git pullis not.git pull=git fetch+git merge(or rebase if configured). Prefergit fetch && git rebase origin/mainfor explicit control.- Shallow clones (
git clone --depth=1) for CI pipelines where full history isn't needed, saves bandwidth and time. git bisectis a binary search through history for the commit that introduced a bug. Incredibly powerful with good atomic commits.- Sparse checkout (
git sparse-checkout) checks out only specific directories, essential for monorepos (e.g., only checkout ).
Quiz
[
{
"id": "git-q1",
"type": "mcq",
"prompt": "What does `git rebase main` do when run from a feature branch?",
"options": [
"Merges main into the feature branch, creating a merge commit",
"Re-applies the feature branch's commits on top of main's latest commit, creating new commit SHAs",
"Fast-forwards the feature branch to main's HEAD",
"Creates a new branch from main with the feature branch's changes"
],
"correct": 1,
"explanation": "Rebase replays your commits on top of the target, creating new commits with new SHAs (same diffs, different parents). No merge commit. The feature branch history becomes linear. The original commits are abandoned (eventually GC'd).",
"difficulty": "medium"
},
{
"id": "git-q2",
"type"
