Git for a Real Website: The iamravi.com Workflow

A structured, practical course in Git and GitHub, taught through the actual workflow used to build and maintain a production Node.js site — not a shallow command dump.

Free preview · 1 of 3 free left
Topic: Foundations Guide intermediate 22 min read

Why this matters

Every command in this guide was chosen because it solves a real problem that comes up in maintaining a live, production Node.js site — not because it's a "Git fundamentals" checklist item. Git is not optional infrastructure for a site like this: it is the only thing standing between "I broke production" and "I have a clean, five-second way back to the last known-good state." Treat the discipline here the same way you'd treat a production deployment checklist, because that's exactly what it is.

Two ground rules run through everything below:

  1. Never guess what's staged. Always look. The single most common way people break a shared repository is committing something they didn't mean to — a stray debug file, an accidental .env, half of an unrelated change. Every workflow below is built around looking before you commit, not trusting memory.
  2. main stays production-safe at all times. Feature work happens on branches or in separate worktrees. main is only ever touched by a deliberate, validated merge — never by "just a quick edit."

What you'll learn

By the end of this guide you'll be able to: inspect a repository's real state before touching it; stage exactly the files you mean to, and catch anything unexpected; commit and push safely; work on multiple features in parallel using branches and worktrees without them interfering; bring a feature branch up to date with main via rebase and resolve conflicts; merge cleanly back into main and verify the merge actually took; and recover from the handful of situations (bad commit, bad merge, accidentally deleted file) that come up in real projects.

Throughout, commands are classified so you know how much confidence to have in each one:

Destructive or history-rewriting commands are marked ⚠ DESTRUCTIVE with what they change, when they're appropriate, a safer alternative, how to verify before running them, and how to recover if something goes wrong.

1. What Git tracks

Git tracks the content of files, not the files themselves as objects on disk in the way a simple backup tool would. Every commit is a full snapshot of the entire tracked tree at that moment (not a diff, even though Git is smart about storing that efficiently), identified by a SHA-1 hash of its contents. This matters practically: a "commit" is never partial. If you stage three files and commit, all three go in together, atomically. There's no such thing as "half a commit."

Git does not track: anything listed in .gitignore (like node_modules/, .env, or generated reports), empty directories, or file permissions in a fully cross-platform way. Understanding what's outside Git's view is as important as understanding what's inside it — see the sections on ignored files and generated-file commits below.

2. Repository, working tree, staging area and commit history

Four concepts, four different "views" of the same project, and confusing them is the source of most Git confusion:

The practical consequence: a change can exist in your working tree, be staged, be committed locally, or be pushed to the remote — four distinct states, and at any moment different files can be in different states simultaneously. Every command in this guide is really just moving something between these four states, deliberately.

3. Initial repository inspection (B)

Before touching anything — especially in a dedicated worktree you don't use daily — get oriented:

Get-Location
git branch --show-current
git status --short --untracked-files=all
git log -1 --oneline
git worktree list

This tells you: where you physically are, which branch you're on, whether anything is already changed or untracked, what the last commit was, and every worktree attached to this repository. Running this block first, every session, costs ten seconds and prevents an entire category of "wait, why am I on the wrong branch" mistakes.

4. Checking repository health before work (A)/(B)

Beyond the basic orientation, confirm your branch's actual relationship to the remote before starting new work:

git fetch origin
git rev-parse HEAD
git rev-parse origin/main

If HEAD and origin/main print different hashes, you're either ahead (you have unpushed local commits), behind (there's new work on the remote you don't have), or diverged (both). Knowing which of these is true before you start editing avoids a much harder merge conflict later.

5. Reading status output (B)

git status --short --untracked-files=all is the single most important command in this entire guide. The short-format codes:

 M  modified, not staged
M   modified, staged
MM  modified, staged, then modified again
??  untracked (brand new file Git has never seen)
A   staged, new file
D   deleted

The --untracked-files=all flag matters specifically for this project: without it, Git collapses an entire new untracked folder into one line, hiding exactly what's inside it — which is precisely the situation where an unwanted file hides successfully. Always use the full flag.

6. Reviewing changes before staging (B)

Before staging anything, read the actual diff — don't trust the filename alone:

git diff --stat
git diff --name-only
git diff --check

--stat shows which files changed and by how many lines — a fast sanity check ("did I really only mean to touch 2 files, not 11?"). --name-only gives a clean list, useful for scripting or double-checking against your intended file list. --check specifically flags whitespace errors (trailing whitespace, conflict markers accidentally left in a file) — cheap to run, and it has caught real problems in this project's history that a visual diff scan missed.

7. Staging an explicit file allowlist (B)

This is the single biggest behavioral difference this workflow has from "the way most people use Git": never stage with a wildcard. Always list every file explicitly:

git add -- path/to/file-one.js path/to/file-two.ejs docs/notes.md

The -- before the file list isn't decorative — it tells Git "everything after this is a pathname, not a flag," which matters if a filename could ever be confused with an option. This is slower to type than git add ., and that's the entire point: the friction is what forces you to consciously name every file, which is what prevents staging something you forgot was sitting in your working tree.

8. Detecting unexpected staged files (B)

After staging your explicit list, verify the stage matches your intent — don't just assume it worked:

git status --short --untracked-files=all
git diff --cached --name-only
git diff --cached --stat
git diff --cached --check

--cached (equivalently --staged) shows you the diff of what's staged, as opposed to plain git diff, which shows what's changed but not yet staged. Compare git diff --cached --name-only's output line-by-line against the file list you intended to stage. If there's a name in that output you didn't type in your git add -- command, something unexpected got staged — stop and investigate before committing.

9. Committing one concern at a time (B)

A commit should represent one coherent change, with a message describing what and why:

git commit -m "Add Mentorship content loader and taxonomy registry"

Resist the urge to bundle an unrelated fix into a commit "while you're in there." If you notice something else that needs fixing, note it and make it its own commit (or its own follow-up). This isn't pedantry — it's what makes git revert (section 21) actually usable later: a revert only cleanly undoes one thing if that commit only did one thing.

10. Pushing safely (A)/(B)

git push origin main
git push -u origin feature/computer-science-mentorship

The -u (--set-upstream) flag on the first push of a new branch links your local branch to a remote-tracking branch, so every push/pull after that can drop the explicit remote/branch names. Only ever push directly to main from the main worktree, after a validated merge — never push a feature branch's history straight onto main.

11. Fetch, pull and pull --ff-only (A)/(B)

git fetch origin
git pull --ff-only origin main

git fetch downloads the remote's latest history but does not touch your working tree or current branch — it's always safe to run. Plain git pull is fetch + merge combined, which can silently create a merge commit you didn't expect if your local branch has diverged. --ff-only refuses to pull if a fast-forward isn't possible, forcing you to notice divergence explicitly rather than getting an unplanned merge commit on main.

12. Comparing local HEAD with origin/main (A)/(B)

git rev-parse HEAD
git rev-parse origin/main

If these two hashes match, your local main and the remote are identical — exactly what you want to confirm immediately after a push, and exactly what this project's own workflow checks before considering a deployment "shipped." If they differ, don't assume why; run git fetch and git log --oneline -5 on both to see the actual difference.

13. Creating feature branches (A)/(B)

git switch main
git pull --ff-only origin main
git switch -c feature/computer-science-mentorship
git push -u origin feature/computer-science-mentorship
git branch --show-current

Always branch from an up-to-date main, never from whatever branch you happened to be on. This project's own history includes a real example of skipping this step — a feature branch was created from another in-progress feature branch instead of from main, silently carrying that branch's entire unmerged history along with it. It was caught early (before any new commits existed on the branch) using exactly the inspection commands in section 3, and corrected with a git reset --hard origin/main (section 24) — which is safe specifically because there was nothing yet to lose.

14. Creating and using Git worktrees (A)/(B)

A worktree is a second working directory for the same repository, checked out to a different branch, so two branches can be worked on at once without constantly switching (and without the risk of editing the wrong branch by mistake):

git worktree add C:\Users\you\Hub\iamravi-site-mentorship feature/computer-science-mentorship
git worktree list

This project runs several worktrees simultaneously — one per active feature (India archive, Knowledge archive, Mentorship, and the main production checkout) — each a genuinely separate folder, so an editor window, a running npm start, and a branch checkout can never be mixed up between features.

15. Working on several features in parallel (B)

With worktrees, "parallel work" means: one terminal/editor per worktree folder, one branch per worktree, and — critically — one concern per branch. Nothing you do in the Mentorship worktree touches files in the India-archive worktree, because they're genuinely different folders on disk, not just different Git states in the same folder. This is what makes the "isolate Mentorship from India" requirement enforceable at the filesystem level, not just a promise.

16. Keeping main production-safe (B)

The rule is simple and absolute: no direct edits to main. All work happens on a feature branch (or in a worktree checked out to one). main only changes via a reviewed, validated merge. This single rule is what makes git switch main always a safe escape hatch — if a feature branch is completely broken, switching back to main guarantees a known-good state, but only if nothing was ever committed to main directly in the meantime.

17. Rebasing a feature branch onto current main (A)/(B)

git fetch origin
git switch feature/computer-science-mentorship
git rebase origin/main

Rebasing replays your feature branch's commits one by one on top of the current tip of main, producing a clean, linear history — as if you'd branched from today's main all along, rather than from whenever you actually started. Do this before merging a feature branch back, not as a daily habit on a long-lived branch, since it rewrites your branch's commit hashes.

⚠ Be aware: if the branch has already been pushed and anyone else (or another one of your own worktrees) has fetched it, rebasing rewrites history that others may be relying on. On a solo project with dedicated worktrees, this risk is low but not zero — always re-check with git worktree list first.

18. Resolving conflicts (B)

When a rebase (or merge) hits a conflict, Git pauses and marks the conflicting file with conflict markers:

<<<<<<< HEAD
your version
=======
incoming version
>>>>>>> branch-name

Open the file, decide what the final content should be (this may be one side, the other, or a manual combination), delete the marker lines entirely, then:

git add -- path/to/resolved-file.js
git rebase --continue

If a conflict looks too tangled to resolve confidently, it's always safe to back out entirely:

git rebase --abort

This returns you to exactly the state before the rebase started — no partial, half-resolved state is left behind.

19. Using --force-with-lease safely after a rebase (B) ⚠ DESTRUCTIVE (on the remote branch)

After rebasing a branch that was already pushed, the remote copy no longer matches your rewritten local history, so a plain push is rejected. The fix:

git push --force-with-lease origin feature/computer-science-mentorship

What it changes: overwrites the remote branch's history with your local, rebased history. When it's appropriate: immediately after rebasing your own feature branch, before merging it into main. Safer alternative: --force-with-lease itself is the safer alternative — it refuses to overwrite the remote if someone else pushed to that branch since your last fetch, unlike plain git push --force, which overwrites unconditionally and can silently destroy someone else's work. Verify before running: git fetch origin immediately beforehand, so your lease reflects the true current remote state. Recovery if something goes wrong: if you force-pushed over commits you needed, and you (or a collaborator) still have a local copy of that branch from before, you can recover the lost commits' hashes via git reflog on whichever machine last had them and re-apply them with git cherry-pick.

Never use plain git push --force — it has no safety check at all and will silently overwrite work with no warning.

20. Merging with --no-ff (A)/(B)

git switch main
git pull --ff-only origin main
git merge --no-ff feature/computer-science-mentorship -m "Merge: Computer Science Mentorship section"

--no-ff forces a real merge commit to be created even when a fast-forward would technically be possible, so the entire feature's arrival is visible as one distinct point in main's history — which matters because it makes the next section's "revert" option a clean, single-step undo of the whole feature if it's ever needed.

21. Reverting a commit or merge (C) ⚠ Rewrites forward history with a new commit (safe), not the past

git revert <commit-hash>
git revert -m 1 <merge-commit-hash>

What it changes: creates a new commit that undoes the changes of an earlier one — it does not delete or rewrite the earlier commit, which is why this is safe to run even on main after a push. When it's appropriate: a merged feature (or a single commit) turns out to be broken in production and needs to come out cleanly, with a clear audit trail of "this was undone and why." Reverting a merge specifically needs -m 1 to tell Git which parent (1 = the branch you merged into, typically main) represents the "mainline" to revert back to. Safer alternative: none needed — revert is already the safe option compared to rewriting history. Verify before running: git show <commit-hash> first, to confirm you're reverting the commit you think you are. Recovery: reverting a revert (git revert <revert-commit-hash>) re-applies the original change, if it turns out the revert itself was a mistake.

22. Restoring files safely (B)

git restore path/to/file.js
git restore --staged path/to/file.js

git restore <file> discards uncommitted, unstaged changes to that file, reverting it to match the last commit — useful when you've made a mess experimenting and want a clean slate for just that one file. git restore --staged <file> un-stages a file without touching its actual content in your working tree — the fix for "I ran git add on something I didn't mean to stage yet."

git restore <file> (without --staged) is destructive to uncommitted work — it throws away changes with no undo, since they were never committed anywhere. Always run git status and git diff first to confirm you're prepared to lose exactly that file's uncommitted changes.

23. Checking protected files

Before staging, it's worth explicitly confirming a known-sensitive file (like .env, or a script your .gitignore deliberately protects) isn't accidentally in your staged list:

git status --short --untracked-files=all
git diff --cached --name-only

Scan the output for any filename that should never be committed. This project's .gitignore explicitly protects .env, .env.local, and generated reports — but a .gitignore rule only helps if the file was never manually git add-ed with an explicit path in the first place (an explicit git add on an ignored file's exact path can still stage it — another reason to actually read git status rather than trusting the ignore rules blindly).

24. Avoiding accidental generated-file commits ⚠ (context for git reset --hard)

git branch backup/my-branch-pre-reset
git reset --hard origin/main

What it changes: moves your current branch's tip and every tracked file in your working tree to match the target (here, origin/main) — any local commits not reachable from elsewhere, and any tracked-file modifications, are discarded. When it's appropriate: your branch was accidentally created from the wrong starting point (as in section 13's real example) and has no commits yet worth keeping, or you deliberately want to throw away local changes and match a known-good remote state exactly. Safer alternative: if you might want any of the current state back, always create a backup branch pointing at the current tip first (git branch backup/<name>) — this costs nothing and makes the reset fully reversible. Verify before running: git log --oneline -5 and git status --short --untracked-files=all on both your current branch and the target, so you know exactly what you're about to discard. Recovery: if you forgot the backup branch, git reflog still shows the discarded commit's hash for a limited time (until Git's garbage collection runs) — git reset --hard <hash-from-reflog> can restore it.

Do not run git reset --hard without a precise recovery context — if you're not certain what you'd lose, stop and inspect first.

25. Handling ignored files (B)

git check-ignore -v path/to/file

This tells you, for a specific file, which .gitignore rule (and from which file, if you have nested .gitignores) is causing it to be ignored — invaluable when a file you expect to see tracked mysteriously isn't showing up in git status at all. This project's own .gitignore history includes a real case where a broad wildcard rule (meant to catch disposable one-off scripts) accidentally also matched permanent, load-bearing files — found and fixed by explicit !filename exceptions once discovered.

26. Cleaning temporary scripts and archives (B)

Disposable, one-off installer/repair scripts (the kind this project's own .gitignore deliberately excludes by naming pattern) should be moved to an archive location, not left loose in the repo root, once they've served their purpose. Confirm what's actually tracked vs. ignored before deleting anything:

git status --short --untracked-files=all
git check-ignore -v <filename>

Never delete an untracked file you haven't first confirmed is safe to lose — an untracked file has no history to recover it from if you're wrong.

27. Reading recent history (A)/(B)

git log --oneline -20
git log -1 --oneline
git show <commit-hash>

git log --oneline -20 gives a fast, scannable view of recent work — useful for re-orienting after time away from a branch. git show <hash> displays the full diff of a specific commit, the right tool when git log's one-line summary isn't enough context to remember what a commit actually did.

28. Diagnosing a dirty worktree (B)

git status --porcelain
git status --short --untracked-files=all

--porcelain produces the same information as --short but in a stable, script-friendly format — useful if you ever want to check "is this worktree clean?" programmatically (an installer script refusing to run on a dirty tree, for example) rather than just reading it visually.

29. Deployment flow from main (A)/(B)

This project's actual deployment pipeline: a push to main triggers Cloud Build, which builds and deploys to Cloud Run automatically. This means the moment git push origin main succeeds, production starts updating — which is exactly why every prior section exists: by the time you reach this command, the change has already been validated, reviewed, and merged deliberately, not arrived at by accident.

git push origin main

There is no separate manual deploy step to run — the push is the deploy trigger. This is precisely why main must never be touched directly (section 16) and why validation must run before every merge (section 30).

30. A complete beginning-to-production checklist

Putting the whole guide together, start to finish:

Get-Location
git branch --show-current
git status --short --untracked-files=all
git log -1 --oneline
git worktree list

git fetch origin
git switch main
git pull --ff-only origin main
git switch -c feature/my-change
git push -u origin feature/my-change

# ... do the work, in small commits ...
git status --short --untracked-files=all
git diff --stat
git diff --name-only
git diff --check
git add -- path/to/file-one.js path/to/file-two.ejs
git status --short --untracked-files=all
git diff --cached --name-only
git diff --cached --stat
git diff --cached --check
git commit -m "Describe exactly one concern"
git push origin feature/my-change

# ... bring the branch up to date before merging ...
git fetch origin
git rebase origin/main
# resolve any conflicts, then:
git push --force-with-lease origin feature/my-change

# run every validator required for this change here, before merging

# ... merge from the main worktree only ...
git switch main
git pull --ff-only origin main
git merge --no-ff feature/my-change -m "Merge: my change"
git rev-parse HEAD
git rev-parse origin/main
git push origin main
git rev-parse HEAD
git rev-parse origin/main

The two git rev-parse pairs at the very end are not decorative — they are the final proof that your local main and the deployed remote main are identical, which is the actual definition of "done" for a change to a live, production site.

What's next

Apply this exact checklist the next time you finish a real feature — not as a memorized script, but by understanding why each step exists, using the section above as reference until it becomes habit. From here, the natural next reads are Writing Idempotent Deployment Scripts and Rolling Back a Bad Deployment Safely, both of which assume this Git workflow as their foundation.

Part of: Freshers, DevOps Beginners, First Year

← Back to Guides

Next → How DNS Resolution Actually Works What actually happens between typing a hostname and getting an IP address back — the lookup chain, caching layers, and why DNS is so often the first suspect in an outage.