Skip to content

1. How Git works

Beginner · 13 min read

Most Git confusion comes from not having a picture of what Git is doing. Once you see that Git stores snapshots of your project, each named by a hash of its content, commands like reset, rebase and cherry-pick stop being magic. This page builds that picture with real commands on a tiny chat app.

Every terminal output on these pages comes from a real run (Git 2.50). Your hashes will differ, because they depend on your name, email and the time.

1.1 Set up once

git config --global user.name "Asha Rao"
git config --global user.email "asha@example.com"     # use the email on your GitHub account
git config --global init.defaultBranch main
git config --global pull.rebase true                  # explained on page 4
git config --list --show-origin                       # see every setting and where it came from

On Windows, also run git config --global core.autocrlf true so line endings don't show up as changes on every line. On macOS and Linux use input.

1.2 A repository and the three areas

git init chat-app
cd chat-app
# create app.py and a .env file with an API key, then:
git status
Output
Initialized empty Git repository in .../chat-app/.git/
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    .env
    app.py

nothing added to commit but untracked files present (use "git add" to track)

git init only created a hidden .git folder. That folder is the repository: all history lives there. Your files are in one of three places:

flowchart LR
    W["Working tree<br/>files you edit"] -- "git add" --> S["Staging area (index)<br/>what the next commit will contain"]
    S -- "git commit" --> R["Repository (.git)<br/>saved snapshots"]
    R -- "git restore / switch" --> W

The staging area is what beginners skip over, and it's what makes Git useful: you choose exactly what goes into each commit. Here we stage app.py and deliberately leave .env (which has a secret) out:

git add app.py
git status --short
git commit -m "Add answer() helper"
Output
A  app.py
?? .env
[main (root-commit) bbae949] Add answer() helper
 1 file changed, 6 insertions(+)
 create mode 100644 app.py

In --short output the left column is the staging area and the right column is the working tree: A means "added to staging", ?? means "untracked", M means "modified but not staged".

1.3 See what changed: diff

Change the default model, then compare:

git status --short
git diff                    # working tree vs staging area: what you haven't staged yet
Output
 M app.py
?? .env
diff --git a/app.py b/app.py
index 2c7fdcb..ec731c5 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,6 @@
 import os

-MODEL = os.getenv("MODEL", "gpt-4o-mini")
+MODEL = os.getenv("MODEL", "claude-opus-5-5")

 def answer(question: str) -> str:
     return f"[{MODEL}] {question}"
git add app.py
git diff --staged --stat    # staging area vs last commit: what WILL be committed
git commit -m "Switch default model to Claude"
git log --oneline
Output
 app.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
[main e44f683] Switch default model to Claude
 1 file changed, 1 insertion(+), 1 deletion(-)
e44f683 Switch default model to Claude
bbae949 Add answer() helper
Command Compares
git diff Working tree ↔ staging area (unstaged changes)
git diff --staged Staging area ↔ last commit (what you're about to commit)
git diff HEAD Working tree ↔ last commit (everything)
git diff main..feature Two branches

Always read git diff --staged before committing. It's the cheapest way to catch a stray API key or a debug print().

1.4 What a commit really is

Ask Git to print the latest commit object:

git cat-file -p HEAD
git cat-file -p HEAD^{tree}
Output
tree 49f3054cc0ba5b0b4ed865a04b5a8c06d8f7c114
parent bbae949bf37eef97f75b7ec4847a1251a4d0bc8c
author Asha Rao <asha@example.com> 1790829000 +0530
committer Asha Rao <asha@example.com> 1790829000 +0530

Switch default model to Claude
100644 blob ec731c5e5de804990ebae53eac36febe1449240b    app.py

A commit is a small text record: a pointer to a tree (a snapshot of the folder), a pointer to its parent commit, the author and the message. The tree lists files, each pointing to a blob (the file's content).

flowchart LR
    C2["commit e44f683<br/>Switch default model"] -->|parent| C1["commit bbae949<br/>Add answer() helper"]
    C2 -->|tree| T2["tree 49f3054"] --> B2["blob ec731c5<br/>app.py (new)"]
    C1 -->|tree| T1["tree"] --> B1["blob 2c7fdcb<br/>app.py (old)"]

Every object is named by the SHA-1 hash of its content. You can compute a blob's name yourself, and it matches what Git printed above:

import hashlib

app_py = '''import os

MODEL = os.getenv("MODEL", "claude-opus-5-5")

def answer(question: str) -> str:
    return f"[{MODEL}] {question}"
'''.encode()

def git_blob_hash(content: bytes) -> str:
    header = f"blob {len(content)}\0".encode()       # Git hashes "blob <size>\0" + the bytes
    return hashlib.sha1(header + content).hexdigest()

print(git_blob_hash(app_py))
print(git_blob_hash(app_py) == "ec731c5e5de804990ebae53eac36febe1449240b")
print(git_blob_hash(app_py.replace(b"claude-opus-5-5", b"gpt-4o-mini")))   # the old version: index 2c7fdcb
Output
ec731c5e5de804990ebae53eac36febe1449240b
True
2c7fdcbbec48eeebc342c1281b15ff7b1efec54b

This design explains a lot of Git's behaviour:

  • Same content, same hash. Unchanged files aren't stored again; the new tree just points at the old blob.
  • History can't be edited silently. A commit's hash covers its tree and its parent's hash, so changing any old commit changes every hash after it. That's why "rewriting history" (amend, rebase) produces new commits, and why rewriting commits others already have causes trouble.
  • Git stores snapshots, not diffs. git diff computes differences on the fly by comparing two trees.

1.5 Keep secrets and junk out: .gitignore

.env is still untracked. One git add . and the API key would be committed forever. Tell Git to ignore it:

# .gitignore
.env
.venv/
__pycache__/
*.pyc
git status --short
git check-ignore -v .env           # which rule ignores this file?
Output
?? .gitignore
.gitignore:1:.env   .env

.env no longer appears at all. Commit .gitignore itself so everyone on the team gets the same rules. Two things to know:

  • .gitignore only affects untracked files. If a file was already committed, ignoring it does nothing: run git rm --cached <file> to stop tracking it (it stays on disk).
  • A secret that was committed is in the history even after you delete the file. See Git for GenAI projects for what to do.

1.6 Reading history

git log --oneline                     # one line per commit
git log --stat -1                     # last commit with the files it changed
git log -p app.py                     # every change to one file, with diffs
git log --author="Asha" --since="2 weeks ago"
git log -S "claude-opus" --oneline    # commits that added or removed this text ("pickaxe")
git show HEAD~1                       # the commit before the latest

HEAD is "the commit you're on now". HEAD~1 is its parent, HEAD~2 the grandparent, and so on.

Interview questions

What's the difference between the working tree, the staging area and the repository?

The working tree is the files on disk you edit. The staging area (index) is the set of changes you've chosen for the next commit. The repository (.git) holds the committed snapshots. git add moves changes from the working tree to the staging area; git commit saves the staging area as a new snapshot.

Does Git store diffs or snapshots?

Snapshots. Each commit points to a tree that describes the whole project; unchanged files are shared between snapshots because objects are addressed by the hash of their content. Diffs are computed when you ask for them. (Pack files compress similar objects with deltas on disk, but that's a storage detail.)

Why does amending or rebasing a commit change its hash?

A commit's hash is computed from its content: tree, parent hash, author, committer, timestamps and message. Changing any of these produces a different object with a different hash, and because each child includes its parent's hash, every commit after it gets a new hash too.

You added .env to .gitignore but it still shows up in git status. Why?

It was already tracked. .gitignore only applies to untracked files. Run git rm --cached .env, commit, and, if it ever contained real keys, rotate them, because they're still in the history.

Practice

  • Make a change, stage only part of it with git add -p, and check git diff vs git diff --staged.
  • Run git cat-file -p on a commit, then its tree, then a blob, and follow the chain by hand.
  • Change one character in app_py in the Python block and see how the hash changes.

Next: 2. Branches and merging Back to: Git & GitHub overview · Notes overview