1 · Why version control exists
Imagine a shared folder where five people edit the same files. Someone overwrites your work. A change breaks everything and nobody remembers what it looked like yesterday. You end up with report_final_v2_REALLY_final.docx. That chaos is exactly the problem version control solves.
A version control system (VCS) records every change to your project over time. It lets you answer three questions that come up every single day on a team:
- What changed, and when? Every save (a commit) is timestamped and attributed to a person.
- Can I go back? Any previous state can be restored — nothing is ever truly lost.
- Can several people work at once without stepping on each other? Yes — that's what branches are for.
Git is the version control system nearly the whole industry uses. It was created in 2005 by Linus Torvalds (the same person who started Linux) and it's distributed: every developer has a full copy of the entire history on their own machine, not just the latest files.
Mental model. Think of Git as a save-game system for code. Every commit is a save point you can return to, branch off from, or compare against.
2 · Git vs. GitHub — not the same thing
This trips up almost everyone at first, so let's be precise:
| Git | GitHub |
|---|---|
| A tool that runs on your computer. | A website / service that hosts Git repositories in the cloud. |
| Tracks history, branches, commits — all offline. | Adds collaboration: pull requests, code review, issues, permissions, CI/CD. |
| Works with no internet and no account. | Is one of several hosts — GitLab, Bitbucket and Azure DevOps do the same job. |
Analogy: Git is the engine; GitHub is the garage where the team parks and works on the cars together. You can use Git alone forever. You use GitHub (or a similar host) so the team has one shared source of truth and a place to review each other's work.
3 · The building blocks & vocabulary
Before commands make sense, you need the nouns. Here are the ones you'll hear every day.
| Term | What it means |
|---|---|
| Repository (repo) | The project folder Git is tracking, including its entire history. |
| Commit | One saved snapshot of your changes, with a message describing them. The atom of Git history. |
| Working directory | The actual files on your disk right now, as you're editing them. |
| Staging area (index) | A "shopping cart" where you gather exactly the changes you want in your next commit. |
| Branch | A movable pointer to a line of work. main is just the default branch name. |
| Remote | A copy of the repo hosted elsewhere (usually on GitHub). The default remote is named origin. |
| Clone | Download a full copy of a remote repo onto your machine. |
| HEAD | A pointer to "where you are right now" — usually the tip of your current branch. |
The three areas — and the two-step save
New developers are often surprised that saving in Git takes two steps: git add then git commit. That's the staging area at work, and it's a feature, not busywork. It lets you commit only some of your changes and leave the rest for a separate, cleaner commit.
# See what's changed and which area each change is in
git status
# Stage specific files into the "cart" for the next commit
git add src/login.js src/login.css
# Or stage everything that changed
git add .
# Snapshot the staged changes with a message
git commit -m "Add password strength meter to login form"
The flow of a change. A change moves through three areas:
Working directory — edit files —→ Staging area — git add —→ Repository — git commit. Then git push sends your commits up to the remote.
Writing good commit messages
A commit message is a note to your future teammates (and future you). The convention: a short imperative summary under ~50 characters, optionally followed by a blank line and more detail.
# Good — says what the commit does, in the imperative
git commit -m "Fix crash when cart is empty at checkout"
# Not helpful — reviewers and future-you learn nothing
git commit -m "stuff"
4 · Branches — what they are and why they exist
A branch is astonishingly simple under the hood: it's just a movable label pointing at a commit. When you make a new commit on a branch, the label moves forward. That's it. Because branches are so cheap, Git encourages you to make lots of them.
Why branch at all? Because a branch is an isolated workspace. Work you do on one branch is invisible to every other branch until you deliberately merge it. That gives the team superpowers:
- Parallel work. Five people can build five things at once without colliding.
- Safety. Experiment freely — if it goes wrong, delete the branch and nothing else is harmed.
- Focus. Each branch holds one coherent piece of work that can be reviewed on its own.
# Create a branch and switch to it in one step
git switch -c feature/user-avatars
# (older, still-common syntax for the same thing)
git checkout -b feature/user-avatars
# List branches; the * marks where you are
git branch
# Jump back to main
git switch main
5 · Feature branches — the everyday pattern
A feature branch is a short-lived branch created for one unit of work — a feature, a bug fix, a small improvement — then merged and deleted. This is the backbone of how modern teams work, and the reason is worth internalising:
- One branch = one reviewable idea. Reviewers can understand the whole change in one sitting.
- Main stays releasable. Unfinished work lives on its own branch, never in the shared, deployable line.
- Easy to abandon. If the feature gets cancelled, you just delete the branch.
Teams usually agree on a naming convention so branches are self-documenting. Common patterns:
feature/checkout-apple-pay # a new capability
fix/nullpointer-on-empty-cart # a bug fix
chore/upgrade-node-20 # maintenance, no user-facing change
docs/api-authentication # documentation only
Keep them small and short-lived. A feature branch that lives for weeks drifts far from main and becomes painful to merge (this pain is called merge conflict hell). Aim to open a pull request within a day or two.
6 · Why you never commit or push directly to main
main (historically master) is the branch the team treats as the single source of truth — the version that is reviewed, tested, and usually deployed. Pushing straight to it skips every safety net the team has built. Here's what going through a branch + pull request buys you, and what pushing directly to main throws away:
- Review. Nobody gets a chance to catch your bug before it becomes everyone's bug.
- Automated checks (CI). Tests, linting and builds normally run on a pull request. Push straight to main and a broken commit lands in the shared line.
- A broken
mainblocks the whole team. Ifmainis what gets deployed, one bad direct push can take down production or block everyone else's work. - Traceability. A pull request links the change to a discussion, an issue, and a reviewer's approval. A raw push to main is context-free.
Most teams enforce this with branch protection rules on GitHub: main is locked so that changes can only arrive through an approved pull request that passes CI. The rule of thumb to memorise:
The golden rule. You don't write to main — you propose to main. All work happens on a branch and enters main through a reviewed, tested pull request. Never git push origin main from your laptop.
7 · Pull requests & code review
A pull request (PR) — called a merge request on GitLab — is a proposal: "Here are the commits on my branch; please review them and merge them into main." It's where collaboration actually happens.
The typical life of a PR:
- You push your feature branch to GitHub and open a PR targeting
main. - Automated CI checks run (tests, linters, build).
- A teammate reviews the diff, leaves comments, and requests changes or approves.
- You push more commits to address feedback — the PR updates automatically.
- Once approved and green, the PR is merged, and the branch is deleted.
# Push your branch and set its upstream on the first push
git push -u origin feature/user-avatars
# Then open the PR — in the browser, or with GitHub's CLI:
gh pr create --base main --fill
Reviewers look for correctness, readability, tests, and whether the change fits the team's conventions. Getting review comments is normal and good — it's how code gets better and how you learn a codebase's norms. It is not criticism of you.
8 · Merge, squash & rebase — and "close"
When a PR is approved, GitHub offers three ways to bring your branch into main. Knowing the difference is exactly the kind of thing that makes you look like you know what you're doing.
Merge commit
Keeps every commit from your branch and adds one extra "merge commit" that ties the two histories together. Full detail is preserved, but the history can look tangled when many branches merge.
Squash merge
Takes all the commits on your branch and combines (squashes) them into a single commit on main. Your messy work-in-progress history — "wip", "fix typo", "actually fix it" — collapses into one clean, meaningful commit. This is the default on many teams because it keeps main's history tidy and readable: one commit per feature.
Squash in one sentence. Ten scrappy commits on your branch become one clean commit on main — so the shared history reads like a changelog, not a diary.
Rebase and merge
Replays your branch's commits one-by-one on top of the latest main, producing a straight line with no merge commit. Powerful, but it rewrites commit history, so there's one iron rule:
Never rebase shared history. Rebasing rewrites commits, which breaks the copies your teammates already have. Rebase only your own branch that nobody else has pulled — never main or a branch others are working on.
| Strategy | History on main | Good when… |
|---|---|---|
| Merge commit | Full, branched | You want to preserve every step and how branches joined. |
| Squash merge | One commit per PR | You want a clean, readable, linear history. Most common default. |
| Rebase & merge | Linear, no merge commit | You want a straight line but still want each commit kept. |
What "close" means
Every PR ends in one of two ways. Merge accepts the work. Close means "we're not taking this" — the PR is shut without merging (the idea was rejected, superseded, or abandoned). Closing does not delete your branch or your commits; you can reopen or repurpose it later. Related: an issue can be closed automatically when a PR that fixes it is merged (writing Fixes #123 in the PR description does this on GitHub).
9 · Stages & environments — where code runs
Your code doesn't just live in one place. It runs in a series of environments (also called stages), each with a different purpose and audience. This is how teams ship changes without breaking things for real users.
Development
Your laptop. Move fast, break things, nobody else sees it.
feature/*Staging
A production-like clone for final testing & QA before release.
develop / releaseProduction
The real thing your customers use. Changes here are careful and reviewed.
mainWhy bother with stages? Because you want to catch problems before real users hit them. Staging is a dress rehearsal: same setup as production, but safe to break. A change earns its way up the ladder — development → staging → production — gaining confidence at each step.
How Git ties into stages
There are two dominant models, and it helps to recognise both:
1. Branch-per-environment. A long-lived branch maps to each stage. Merging into that branch is what deploys to the matching environment:
| Branch | Deploys to | Who sees it |
|---|---|---|
feature/* | your machine / a preview URL | just you |
develop | Staging | the team & QA |
main | Production | real customers |
In this model, promoting a change is literally a merge: feature/x → develop (now on staging), and once verified, develop → main (now live). The branch you merge into decides where the code runs.
2. Trunk-based + tags/releases. Many modern teams keep one main branch and deploy from it, marking releases with tags (e.g. v1.4.0). Which stage gets which version is handled by the deployment pipeline, not by separate long-lived branches.
# Mark a specific commit as a release
git tag -a v1.4.0 -m "Release 1.4.0"
git push origin v1.4.0
The key idea. A branch or tag in Git gets connected — usually by an automated CI/CD pipeline — to a running environment. Git decides what code; the pipeline decides where it runs.
10 · Environment-specific configuration: variables & secrets
Here's a problem stages create: the same code needs to behave differently in each environment. Staging should talk to the staging database; production to the production database. You must never hard-code those differences into the source — because the source is shared, versioned, and public to the whole team.
The solution is configuration that lives outside the code, injected per environment. This is a core idea of the widely-cited Twelve-Factor App methodology: strictly separate config from code.
Environment variables
An environment variable is a named value the operating system hands to your program when it starts. Your code reads it instead of hard-coding the value:
# Same code, different value per environment
DATABASE_URL # → points at the staging DB on staging, prod DB on prod
LOG_LEVEL # → "debug" locally, "error" in production
FEATURE_NEW_UI # → "on" in staging while you test, "off" in prod
Locally, these usually live in a .env file that your app loads on startup:
# .env (local development only)
DATABASE_URL=postgres://localhost:5432/myapp_dev
LOG_LEVEL=debug
Non-secret config vs. secrets
There are two kinds of configuration, and the distinction is critical:
- Plain variables — non-sensitive settings like
LOG_LEVEL, a feature flag, or a public API base URL. Not harmful if seen. - Secrets — passwords, API keys, database credentials, signing tokens. If these leak, someone can steal data, run up bills, or take over accounts.
Never commit secrets to Git. Git history is forever — deleting a secret in a later commit does not remove it from history, and on a public repo bots scrape leaked keys within minutes. A committed secret must be treated as compromised and rotated (replaced) immediately.
The first line of defence is .gitignore — a file listing paths Git should never track. Your .env belongs there, alongside a safe, committed template so teammates know which variables to set:
# .gitignore
.env
.env.local
*.pem
# .env.example — committed, holds keys but NO secret values (safe defaults only)
DATABASE_URL=
STRIPE_SECRET_KEY=
LOG_LEVEL=debug
Where secrets actually live per environment
On real environments you don't ship a .env file at all — you inject secrets through a secret manager provided by your platform, so the values never touch the repo:
- GitHub Actions → repository/environment Secrets (used by your CI/CD pipeline).
- Cloudflare Workers →
wrangler secret put NAME(encrypted, never inwrangler.jsonc). - Cloud platforms → AWS Secrets Manager, Google Secret Manager, HashiCorp Vault, Doppler, and similar.
# Example: set a secret on a Cloudflare Worker (prompts for the value)
npx wrangler secret put STRIPE_SECRET_KEY
# Your code reads it at runtime — the value is never in the source
11 · Package managers — why Homebrew (and friends) matter
No real project is built from scratch. You rely on other people's software: the language runtime, a database, dozens of libraries. A package manager is a tool that installs, updates and removes that software for you — reliably, and in the right versions — instead of you hunting down installers by hand.
Homebrew (brew) is the standard package manager for macOS. It installs the tools your machine needs: languages, databases, CLIs.
# Install the tools a project needs — one command each, no manual downloads
brew install node
brew install git
brew install postgresql@16
# Keep everything current
brew update && brew upgrade
Why this matters so much on a team:
- Reproducibility. "Works on my machine" bugs usually come from mismatched tool versions. Package managers let everyone install the same versions with one command.
- No manual, error-prone installs. One command instead of a wiki page of download links and PATH edits.
- Dependency resolution. Installing one thing pulls in whatever it needs, automatically.
- Easy cleanup & upgrades. Remove or update a tool without leaving debris behind.
There's a package manager at every layer. Homebrew installs system tools; language-level managers install the libraries inside your project:
| Manager | Ecosystem | Installs |
|---|---|---|
brew | macOS (system) | Languages, databases, CLI tools |
npm / pnpm / yarn | JavaScript / Node | JS libraries into node_modules |
pip / uv | Python | Python packages |
apt / dnf | Linux | System packages (on servers) |
Lockfiles — the "same versions for everyone" guarantee
When you install project libraries, the manager writes a lockfile (package-lock.json, pnpm-lock.yaml, uv.lock) recording the exact version of every package. This file is committed to Git — it's how the whole team, and every environment, ends up with byte-for-byte identical dependencies.
# Reads package.json, installs deps, writes/updates the lockfile
npm install
# On CI/other machines: install EXACTLY what the lockfile says
npm ci
Rule of thumb. Commit the lockfile; never commit the installed packages. node_modules/ is huge and machine-specific, so it goes in .gitignore. The lockfile is tiny and is the recipe to rebuild it identically anywhere.
12 · Your daily workflow, end to end
Here's how all of the above fits together in a normal day. This is the loop you'll repeat hundreds of times — commit it to muscle memory.
# 1. Start from an up-to-date main
git switch main
git pull
# 2. Branch off for your task
git switch -c feature/search-filters
# 3. Do the work, then stage and commit in small logical chunks
git add .
git commit -m "Add category filter to search results"
# 4. Push your branch to GitHub
git push -u origin feature/search-filters
# 5. Open a pull request into main and let CI + a reviewer check it
gh pr create --base main --fill
# 6. Address review feedback with more commits, then push again
# (git add . — because -a alone would skip any NEW files you created)
git add .
git commit -m "Handle empty filter state"
git push
# 7. Once approved & green: squash-merge in GitHub, delete the branch. Done.
Stuck? This is your safety command. git status tells you which branch you're on, what's staged, and what's changed. When in doubt, run it before doing anything else — it has rescued every developer alive.
13 · Quick glossary
| Repo | A project tracked by Git, with its full history. |
| Commit | A saved snapshot of changes with a message. |
| Branch | An isolated line of work; a movable pointer to a commit. |
| main | The shared, releasable source of truth. Protected; changed only via PR. |
| Remote / origin | The hosted copy of the repo (e.g. on GitHub); origin is its default name. |
| Clone / pull / push | Copy a repo down · fetch & merge others' changes · send your commits up. |
| Staging area | Where git add gathers changes for the next commit. |
| Pull request (PR) | A proposal to merge a branch, with review and CI. |
| Squash merge | Collapsing a branch's commits into one clean commit on merge. |
| CI/CD | Automated checks (CI) and automated deployment (CD). |
| Environment / stage | Where code runs: development, staging, production. |
| Secret | Sensitive config (keys, passwords) kept out of Git entirely. |
| Package manager | A tool that installs software & libraries (brew, npm, pip…). |
| Lockfile | Records exact dependency versions so everyone installs the same thing. |
You now have the vocabulary and the mental models to be dangerous — in the good way. The fastest way to make it stick is to do it: clone a repo, branch, commit, and open your first pull request. Welcome to the team. 🚀