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¶
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:
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:
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
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:
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
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 diffcomputes 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:
.env no longer appears at all. Commit .gitignore itself so everyone on the team gets the same rules. Two
things to know:
.gitignoreonly affects untracked files. If a file was already committed, ignoring it does nothing: rungit 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 checkgit diffvsgit diff --staged. - Run
git cat-file -pon a commit, then its tree, then a blob, and follow the chain by hand. - Change one character in
app_pyin the Python block and see how the hash changes.
Next: 2. Branches and merging Back to: Git & GitHub overview · Notes overview