Skip to content

Git & GitHub

Git is a distributed version control system: it records the full history of a project, lets many people work on it at once, and keeps a complete copy of that history on every machine. Almost every confusing thing about Git becomes obvious once you understand its data model, so this page starts there rather than with commands.

In older systems (Subversion, CVS, Perforce) there is one server that owns the history. You “check out” files from it, and you need it online to commit, view history, or branch.

Git has no privileged server. When you clone a repository you get the entire history, not just the latest files. Committing, branching, diffing, searching history, and reverting all happen locally with no network. Sharing is a separate, explicit step: you push your commits to another copy, or fetch theirs.

GitHub is not Git. GitHub is a hosting service that stores one copy of a repository and wraps it in a website (pull requests, issues, reviews, CI). That copy is technically no more authoritative than yours — teams just agree to treat it as the source of truth.

This is the single most important idea, and most people learn it backwards.

Many version control systems store a file’s history as an original plus a list of changes: “line 12 changed, line 40 deleted”. Git does not. Every commit stores a complete snapshot of the entire project tree. When you view a diff, Git computes it on the fly by comparing two snapshots.

Git avoids wasting space because it is content-addressed. Every object is stored under the SHA-1 hash of its own contents (newer repositories can use SHA-256, but SHA-1 is still the default in Git 2.x). Identical content produces an identical hash, so if a file is unchanged between two commits, both snapshots point at the exact same stored object. A thousand commits that never touch LICENSE store LICENSE exactly once.

There are four object types:

Object What it holds
blob The raw bytes of one file. No name, no permissions, no history.
tree A directory listing: names, modes, and the hashes of the blobs/trees inside it.
commit A pointer to one root tree, plus parent commit(s), author, committer, date, message.
tag An annotated tag: a name, a target object, a tagger, and a message.

So a commit is: snapshot + parent(s) + metadata, addressed by a hash.

You can look at the raw objects yourself:

Terminal window
git cat-file -p HEAD
tree 8d9f2b1c3e4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c
parent 4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a
author Ada Lovelace <ada@example.com> 1712345678 +0100
committer Ada Lovelace <ada@example.com> 1712345678 +0100
Add parser for config files

That is the entire commit object. Note what is not there: no diff, no file list, no branch name. Just a tree hash and a parent hash.

Terminal window
git cat-file -p HEAD^{tree}
100644 blob a1b2c3d4... README.md
100644 blob e5f6a7b8... package.json
040000 tree 9c0d1e2f... src

Because each commit names its parent, commits form a DAG — a directed acyclic graph. Follow parents backwards and you get history. A commit with two parents is a merge. A commit with no parent is the first commit in the repository.

A ── B ── C ── D (main)
\
E ── F (feature)

Everything else in Git is a label on this graph. A branch is a movable pointer to one commit. A tag is a fixed pointer. HEAD points at whichever branch you have checked out. There is no separate “branch storage” — deleting a branch just removes a pointer; the commits are untouched until garbage collection eventually removes anything unreachable.

Check whether you have it:

Terminal window
git --version
# => git version 2.43.0

Install if needed:

Terminal window
# Debian / Ubuntu
sudo apt install git
# macOS (Xcode command line tools, or Homebrew)
xcode-select --install
brew install git
# Windows
winget install --id Git.Git

Git stamps your name and email into every commit. Set them once, globally:

Terminal window
git config --global user.name "Ada Lovelace"
git config --global user.email "ada@example.com"

Set the default branch name for new repositories. Git’s built-in default is master; most projects and hosts now use main:

Terminal window
git config --global init.defaultBranch main

Set the editor used for commit messages, interactive rebases, and merge conflict prompts:

Terminal window
git config --global core.editor "nvim"
# or: "code --wait", "vim", "nano", "emacs"

The --wait flag matters for GUI editors — without it the editor returns immediately and Git thinks you saved an empty message.

A couple of quality-of-life settings that are almost always worth it:

Terminal window
# Colour output
git config --global color.ui auto
# Refuse to guess what `git pull` should do; you must choose merge or rebase
git config --global pull.rebase false # always merge (the classic default)
# or
git config --global pull.rebase true # always rebase your local commits on top
# Automatically set up the remote branch on first push
git config --global push.autoSetupRemote true # Git 2.37+

Inspect what is set and where it came from:

Terminal window
git config --list --show-origin
git config user.email # the effective value here

Git reads config from three files, in increasing priority:

Level Flag Location
System --system /etc/gitconfig
Global (per user) --global ~/.gitconfig or ~/.config/git/config
Local (per repo) --local .git/config in the repository

Local wins over global, which wins over system. This is how you use a work email in one repository and a personal one everywhere else:

Terminal window
cd ~/work/project
git config --local user.email "ada@work-corp.com"

Every Git command moves content between three places. Learning these names removes most of the confusion around add, reset, and restore.

Area Also called What it is
Working directory working tree The actual files on disk that you edit.
Staging area index, cache A file (.git/index) listing exactly what the next commit will contain.
Repository object database .git/objects — every commit, tree, and blob ever created.
working directory staging area repository
(files you edit) (.git/index) (.git/objects)
│ │ │
│ git add ───────────►│ │
│ │ git commit ───────►│
│◄────────── git restore│ │
│◄──────────────────── git restore --source=… │

The staging area is Git’s distinctive feature and its most useful one. It lets you build a commit deliberately instead of committing whatever happens to be in your editor. You can change ten files and commit three of them, or even commit only some hunks of one file.

Terminal window
# Edit files...
git status # see what changed and what is staged
git add src/parser.js # stage one file
git add -p src/api.js # stage selected hunks of another
git commit -m "Fix off-by-one in header parsing"

Files in the working directory are in one of these states:

  • Untracked — Git has never seen this path.
  • Unmodified — matches the last commit.
  • Modified — differs from the last commit, not staged.
  • Staged — the current version is recorded in the index, ready to commit.

Two ways in. Either turn an existing directory into a repository:

Terminal window
mkdir my-project && cd my-project
git init
# => Initialized empty Git repository in /home/ada/my-project/.git/

…or copy an existing one:

Terminal window
git clone https://github.com/user/repo.git
git clone git@github.com:user/repo.git # SSH
git clone https://github.com/user/repo.git dir # into a specific directory

git init creates a single directory, .git/, containing the entire repository. Everything else in the folder is just your working directory. Delete .git/ and you have a plain folder with no history; copy .git/ and you have copied the whole project history.

git clone does four things: creates the directory, runs init, adds a remote named origin pointing at the source, fetches all objects, then checks out the default branch.

Terminal window
cd my-project
echo "# My Project" > README.md
git status
# => Untracked files: README.md
git add README.md
git commit -m "Initial commit"
# => [main (root-commit) 3f2a1b0] Initial commit
# => 1 file changed, 1 insertion(+)

Look at what you made:

Terminal window
git log --oneline
# => 3f2a1b0 (HEAD -> main) Initial commit
git show --stat HEAD

You will rarely touch these directly, but knowing they exist demystifies a lot:

Path Contents
.git/objects/ All blobs, trees, commits, tags — the actual history.
.git/refs/heads/ One small file per branch, containing a commit hash.
.git/refs/tags/ One file per tag.
.git/HEAD Usually the text ref: refs/heads/main.
.git/index The staging area (a binary file).
.git/config Repository-local configuration and remotes.
refs/heads/main
cat .git/HEAD
cat .git/refs/heads/main
# => 3f2a1b04c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0

A branch really is a 41-byte file. That is why creating and deleting branches is instant.

Hold these five sentences and the rest of Git follows:

  1. A commit is an immutable snapshot of the whole tree, plus its parent(s), identified by a hash of its own contents.
  2. Commits form a DAG; history is “follow the parent pointers”.
  3. Branches and tags are just labels pointing at commits — cheap to make, cheap to delete.
  4. HEAD is where you are; it usually points at a branch, which points at a commit.
  5. Content flows working directory → index → repository, and every “undo” command is really “move a pointer” or “copy content backwards between those three areas”.
  • Git stores snapshots, not diffs; diffs are computed on demand.
  • Objects are addressed by the hash of their content, which is why unchanged files cost nothing and why any edit to a commit produces a new commit.
  • Configure user.name, user.email, init.defaultBranch, and core.editor before your first commit.
  • The three areas — working directory, index, repository — explain the entire command surface.
  • .git/ is the repository; everything outside it is a working copy you can regenerate.