← Blog

Git and GitHub: the mental model, in pictures

· 18 min read

GitGitHubVS CodeFundamentals

Most people learn git as a list of commands to type in the right order. That works until the first unexpected message, and then nothing makes sense – because the commands were never the hard part. Almost every git confusion I have seen, in teams and in the classroom at ELTE, is a missing picture: where a change currently lives, what a branch actually is, what "origin" refers to.

So this post is the pictures: 12 figures, a small example in each, and only as many commands as the pictures need. For the day-to-day I use VS Code, so every step shows the button next to the command it runs – you should know both, because the button is faster and the command is what every error message and every answer online will speak.

The whole model in one breath: a commit is a snapshot of your project, a branch is a name pointing at a commit, and GitHub is a place where a copy of those commits lives so other people can reach them. Everything else is moving snapshots between places.

Git is not GitHub

Git is a program on your machine. It records the history of a folder and needs no account and no internet. GitHub is a website that hosts git repositories and builds collaboration on top of them. GitLab and Bitbucket are alternatives to GitHub; there is no alternative you need for git.

your laptop — gitlocal repositoryfull history · works offlinecommitworking filesgithub.com — hostingremote repositorythe copy everyone shareson top of the repo, GitHub adds:pull requests · code reviewissues · Actions (CI)releases · web UIa teammate — gitlocal repositoryanother full copyworking filespushpullpushpullGit works without a network.Only clone, fetch, pull and push talk to GitHub.
Fig. 1 · Every clone is a complete repository with the full history – there is no “server copy” and “client copy”, only copies that agree to treat one of them as the meeting point.

This is also why "I only want to download one folder" is awkward with git: a repository isn't a folder tree you pick from, it is a sequence of whole-project snapshots. You take the history or you don't.

Set up once

Install git and VS Code, then tell git who you are and which editor to use. You do this once per machine.

# who you are: stamped into every commit you make
git config --global user.name  "Your Name"
git config --global user.email "you@example.com"

# new repositories start on "main"
git config --global init.defaultBranch main

# when git needs an editor, open a VS Code tab and wait until I close it
git config --global core.editor "code --wait"

# when my branch and the remote have both moved, pull should merge
git config --global pull.rebase false

# line endings: Windows
git config --global core.autocrlf true
# line endings: macOS / Linux
git config --global core.autocrlf input

The core.editor line matters more than it looks. Out of the box git opens a terminal editor whenever it wants text from you – a merge message, for example – and that is where many beginners get stuck. With this setting it opens a normal VS Code tab instead: type, save, close the tab, and git carries on. If code isn't recognised on macOS, run Shell Command: Install 'code' command in PATH from the Command Palette first.

The pull.rebase false line answers a question git would otherwise stop and ask you later: when both you and the remote have new commits, a pull should merge them. Recent git versions refuse to guess. (Rebasing is the other answer; it is a fine tool, and a bad first one.)

Line endings differ between Windows and everything else, and without the autocrlf setting a mixed team sees whole files flagged as changed. In a shared project, also commit a .gitattributes file containing * text=auto, so the rule travels with the repository instead of depending on everyone's laptop.

Signing in to GitHub

GitHub doesn't accept your account password from git. The path of least resistance: clone with the HTTPS URL and let the sign-in happen in the browser. VS Code does this by itself the first time you clone or publish, and Git for Windows ships with a credential manager that does the same for the terminal. SSH keys are the alternative many developers prefer – a one-time set-up, no prompts afterwards – and GitHub's guide walks through it. Both reach the same repository; pick one and move on.

A commit is a snapshot

A commit is not "the changes I made". It is the complete state of the project at one moment, plus a message, an author, a time, and a pointer to the commit that came before. Its ID is a hash of all that, which is why it looks like a1f93c2 rather than "version 3".

oldernewerparentparenta1f“Add home page”snapshotindex.html · newstyle.css · newb72“Restyle the header”snapshotindex.html · samestyle.css · changedc3e“Add contact form”snapshotindex.html · changedstyle.css · samecontact.js · newBlue = stored new in this commit. Dashed = unchanged, so git points at the copy it already has.
Fig. 2 · Three commits. The arrows point backwards, from child to parent – a commit knows where it came from, never what comes after. Storing full snapshots is cheap because unchanged files are shared, not copied.

"Changes" are something git computes by comparing two snapshots. That comparison works line by line, which is why git loves plain text and is unhelpful with Word documents or images – for those it can only say "this file is different now".

$ git diff
--- a/login.ts
+++ b/login.ts
@@ -40,7 +40,7 @@ export function login(form: LoginForm) {
   const user = await findUser(form.email);
-  if (user.password == form.password) {
+  if (await verifyHash(form.password, user.passwordHash)) {
     return createSession(user);

In VS Code you rarely read a diff in this form: click a file in the Source Control view and you get the same information side by side, old on the left, new on the right. Read it before every commit. It is the cheapest code review you will ever get.

The three areas

Between "I edited a file" and "it is in the history" there is a stop that surprises everyone coming from Save buttons: the staging area. It is a draft of the next commit. You choose what goes into it, so that one commit can be one logical change even if you touched ten files for three different reasons.

Working directorythe files you editStaging areathe next commit, draftedRepositorythe history (.git folder)git addgit commitunstageundo commitVS Code: + Stage Changes− Unstage ChangesVS Code: ✓ Commit (Ctrl+Enter)··· → Undo Last CommitDiscard Changes (git restore <file>) throws edits away for good — the one action here with no undo.
Fig. 3 · Where a change lives. Forward is always two steps: stage, then commit. Going back is safe everywhere except Discard Changes, which deletes work that was never stored anywhere.
git init                      # or VS Code: Initialize Repository
git status                    # what does git see?
git add index.html style.css  # stage exactly these
git commit -m "Add home page"

In VS Code all of this is one panel – the Source Control view, Ctrl+Shift+G. Once you see that its two groups are two of the three areas, the panel stops being a list of files and becomes a map.

SOURCE CONTROLMessage (Ctrl+Enter to commit)✓ CommitStaged Changes1login.ts−MChanges2app.ts↶ +Mnotes.md↶ +UThe commit messagewhat changed, and whyCommit = git commitStaged Changes = the staging areaexactly this goes into the next commit− unstages a fileChanges = your working directoryedits git sees but won’t commit yet+ stages a file (git add) · ↶ discards itBadges: M modified · U untracked (new) · D deleted
Fig. 4 · The VS Code Source Control view, as a wireframe. If Staged Changes is empty when you press Commit, VS Code offers to stage everything for you – convenient, but it skips the step where you decide what belongs together.

A word on commit messages, since you will write thousands: say what changed and why in one line, in the imperative – "Fix redirect after login", not "fixed stuff". The diff already shows how. Future you, reading the history to find when something broke, is the audience.

Branches are names, not copies

The word "branch" suggests a copy of the project. It isn't. A branch is a name that points at one commit. That is the entire data structure. Creating a branch writes one tiny file; it is instant no matter how large the project is, which is why git users create branches for everything.

a1fb72f55d90e41mainfeature/loginHEADA branch is a name for a commitOn disk: a 41-byte file with a hash.Creating one copies nothing.HEAD is where you areCommit, and the branch HEAD pointsat moves forward to the new commit.
Fig. 5 · Two branches after some work on each. The commits a1f and b72 belong to both histories – nothing was duplicated. HEAD marks the branch you are on; in VS Code that is the name in the bottom-left corner of the Status Bar.
git switch -c feature/login   # create the branch and move HEAD onto it
# ...edit, stage, commit, as often as you like...
git switch main               # go back; your files change to match main
git merge feature/login       # bring the work in

In VS Code, click the branch name in the Status Bar: the list that opens lets you switch branches or create a new one. When you switch, git rewrites the files in your working directory to match that branch's snapshot – the files on disk really change, and your editor tabs update with them.

The one scary message: detached HEAD

HEAD normally points at a branch. If you check out a specific commit instead – to look at an old version, say – HEAD points straight at that commit and git tells you that you are in "detached HEAD" state. It sounds like an accident. It is just a description.

Normal: HEAD → a brancha1fb72c3emainHEADnew commits move main along with youDetached: HEAD → a commita1fb72c3ex77mainHEADcommits made herebelong to no branchKeep the work: git switch -c rescue · Just looking around: git switch main
Fig. 6 · Looking around in detached HEAD is completely safe. The only risk is committing there and then switching away: no branch name points at those commits, so nothing leads back to them.

Merging, and merge conflicts

Merging means "make this branch contain that branch's work too". Depending on what happened in the meantime, git does one of two things – and knowing which one explains every history graph you will ever look at.

Fast-forward — main didn’t move meanwhilea1fb72c3ed90featuremainmainthe label slides forward, no new commitMerge commit — both sides moveda1fb72e41c3ed90m9cfeaturemainm9c has two parents: it joins the historiesSame two commands both times: git switch main, then git merge feature
Fig. 7 · Left: nobody touched main while you worked, so there is nothing to combine and main simply moves forward. Right: both sides have new commits, so git creates a merge commit – the only kind of commit with two parents.

Git combines changes automatically as long as the two sides edited different lines. When both sides changed the same lines, there is no correct automatic answer, so git stops and hands the decision to you. That is a merge conflict. It is not an error and nothing is broken – it is a question.

common ancestortitle = "Hi"title = "Home"main · Currenttitle = "Start"feature · Incomingsame line, two answersgit stops and asks youon main: git merge featureVS Code — 3-way merge editorIncoming · featuretitle = "Start"Current · maintitle = "Home"Result — one side, both, or your own rewritetitle = "Home"Complete Merge→ file is staged → commit the merge
Fig. 8 · How a conflict arises, and where you answer it. “Current” is the branch you are on, “Incoming” is the branch you are merging in. The result pane starts from the common ancestor and you tick which side to take – or type something better than both.

Underneath, git writes both versions into the file between markers, and resolving a conflict means producing the text you actually want and deleting the markers:

<<<<<<< HEAD
title = "Home"
=======
title = "Start"
>>>>>>> feature

VS Code lists conflicted files under Merge Changes. Open one and you get inline actions above each conflict – Accept Current Change, Accept Incoming Change, Accept Both Changes – or press Resolve in Merge Editor for the three-pane view in Fig. 8. When every conflict is settled, Complete Merge stages the file; commit, and the merge is done. Two habits keep conflicts small: merge often, and keep branches short-lived.

Local and remote

A remote is another copy of the repository that yours knows the address of. The one you cloned from is named origin by convention. Four commands cross the network; everything else in this post happens entirely on your machine.

working directorystaging arealocal repositoryremoteorigin · GitHubgit addgit commitgit pushgit fetchgit pull = fetch + mergegit clone (first time: the whole history)downloads only — your files don’t change
Fig. 9 · Which command moves what, and how far. The important asymmetry: fetch only updates your local repository, so it is always safe. pull goes all the way into your files, because it is a fetch followed by a merge.
# an existing project
git clone https://github.com/you/project.git

# a new one: create an empty repo on GitHub, then
git remote add origin https://github.com/you/project.git
git push -u origin main       # -u: remember that main follows origin/main

In VS Code: Git: Clone from the Command Palette for an existing project, or the Publish to GitHub button in the Source Control view for a new one – it creates the GitHub repository and pushes in one go.

One more name completes the picture. Your repository keeps a bookmark called origin/main: where main was on GitHub the last time you talked to it. It is not live. Comparing it with your own main is how git – and VS Code – can tell you that you are ahead, behind, or both.

a1fb72c3ed90your new commita teammate’s commit, fetchedmainHEADorigin/mainVS Code status bar, bottom-leftmain1↓ 1↑1↓ origin/main has a commit you don’t1↑ you have a commit GitHub doesn’tClick it to sync: pull, then push.origin/main is your last-known picture ofGitHub’s main. Only fetch and pull update it.
Fig. 10 · The state after a fetch: you and a teammate both committed on top of b72. A pull would now merge d90 into your main (Fig. 7, right side); a push before that would be rejected, because GitHub's main has a commit yours doesn't.

That rejected push is the most common first "error" in team work, and the message says exactly what to do: pull first, then push. Git refuses because accepting would throw away your teammate's commit.

The GitHub flow

Put the pieces together and you get the workflow most teams use. main always works; nobody commits to it directly; every change arrives through a pull request – a page on GitHub that says "please merge my branch", shows the diff, and gives teammates and automated checks a place to respond before anything lands.

your laptop1 · new branchgit switch -c fix/login2 · small commitsstage · commit · repeat6 · back to mainpull · delete the branchGitHub3 · open a pull request“please merge my branch”4 · review + checkscomments · CI is green5 · mergemain gets the workgit pushchanges requestedgit pull
Fig. 11 · One piece of work, from branch to merged. The dashed arrow is the normal case, not a failure: review comments lead to more commits on the same branch, and the pull request updates itself when you push them.
git switch main && git pull        # start from the latest main
git switch -c fix/login-redirect   # one branch per piece of work
# ...edit · stage · commit...
git push -u origin fix/login-redirect
# open the pull request on GitHub, get it reviewed and merged, then
git switch main && git pull
git branch -d fix/login-redirect   # done its job (-D if the PR was squash-merged)

The pull request is the most valuable thing GitHub adds to git. It turns "my code" into "our code" at a defined moment, with a record of what was discussed. Keep them small: a reviewer can genuinely read 200 lines, and will only skim 2,000.

Undoing things

Git almost never loses work that was committed. The right way to undo depends on a single question: how far did the mistake travel?

editednot staged yetgit restore <file>VS Code: Discard Changesthe edits are lost for goodstagednot committed yetgit restore --staged <file>VS Code: − Unstage Changesnothing is lostcommittednot pushed yetgit reset --soft HEAD~1VS Code: Undo Last Commitor fix it in place: commit --amendpushedothers may have itgit revert <hash>a new commit that undoes itdon’t rewrite shared historythe further right, the more people have seen it — so undo by adding, not by erasing
Fig. 12 · Four stages, four tools. The two red spots are the ones to respect: discarding unstaged edits destroys work git never stored, and rewriting pushed history destroys work that is no longer only yours.

git revert deserves a sentence, because it is the safe default once something is pushed: it doesn't delete the bad commit, it adds a new commit that does the opposite. History stays true, nobody's copy breaks, and the revert itself can be reverted.

The opposite tool is git push --force, which overwrites the remote branch with yours. On a branch other people use, it silently deletes their commits. If you must rewrite a branch that is only yours – after an amend, say – use git push --force-with-lease, which refuses if someone else pushed in the meantime. Never force-push main.

And when you need to put half-finished work aside for ten minutes – an urgent fix on another branch – that is git stash (VS Code: Git: Stash). git stash pop brings it back.

What not to commit

A repository should contain what a human wrote and nothing a machine can regenerate. A .gitignore file in the project root lists the patterns git should pretend not to see; GitHub maintains ready-made templates for most languages.

.gitignore

# dependencies: reinstalled from package.json, never committed
node_modules/

# build output: rebuilt from source
dist/
build/

# secrets and local settings
.env
.env.local

# OS and editor noise
.DS_Store
Thumbs.db
*.log
  • Dependencies and build output bloat every clone forever and produce meaningless diffs. Rebuild them from source.
  • Large binaries – installers, videos, datasets – don't diff and never leave the history. Attach built programs to a GitHub Release instead: tag the commit (git tag v1.0.0, git push origin v1.0.0) and upload the files there.
  • Secrets – API keys, passwords, .env files. This is the one that hurts. If a secret was ever pushed, deleting it in a later commit does not help: it is still in the history, and every clone has it. Treat it as leaked and rotate it.

Note that .gitignore only affects files git isn't tracking yet. If something was already committed, remove it from tracking with git rm --cached <file> and then ignore it.

Cheat sheet

Everything above, by intent. The VS Code names are Command Palette commands (Ctrl+Shift+P) or buttons in the Source Control view.

I want to…VS CodeCommand
start tracking a folderInitialize Repositorygit init
get an existing projectGit: Clonegit clone <url>
see what changedSource Control view · click a file for the diffgit status · git diff
stage a file+ next to the filegit add <file>
commitCommit · Ctrl+Entergit commit -m "…"
create a branchGit: Create Branchgit switch -c <name>
switch branchbranch name in the Status Bargit switch <name>
upload my commitsPublish Branch · Sync Changesgit push
download new commitsSync Changesgit pull
merge a branch into mineGit: Merge Branchgit merge <name>
park unfinished workGit: Stashgit stash · git stash pop
browse the historySource Control Graphgit log --oneline --graph

Older tutorials use git checkout for both switching branches and restoring files. It still works; git switch and git restore are the newer commands that split those two jobs apart, and they are what I'd learn today.

Takeaways

  1. Git is local, GitHub is a meeting point. Only clone, fetch, pull and push use the network.
  2. A commit is a snapshot that points at its parent. Diffs are computed, not stored.
  3. Three areas: working directory → staging area → repository. The Source Control view is a map of the first two.
  4. A branch is a name for a commit. Creating one costs nothing, so create one for every piece of work.
  5. A conflict is a question, not an error. Current is you, Incoming is them, the result is your call.
  6. pull = fetch + merge. When a push is rejected, pull first.
  7. Undo by how far it travelled. Once it is pushed, revert – don't rewrite.
  8. Never commit secrets. If it happened, rotate the secret; deleting the file is not enough.

Git's reputation for being hard comes almost entirely from meeting the commands before the model. With the pictures in your head, the error messages start reading like what they are: git telling you, fairly precisely, which arrow in which figure it couldn't follow.

Further reading

  • Pro Git – the free, official book. Chapters 2 and 3 cover everything here in depth.
  • Source Control in VS Code – the official tour of the features used in this post.
  • GitHub flow – GitHub's own description of the branch-and-pull-request workflow.
  • Gitting Started by Mátyás Budavári – a friendly, command-line-first introduction that pairs well with this one.