Git is one of those tools we use every day, yet rarely stop to question what’s happening under the hood.
Commands like:
git commit
git checkout
git branch
feel intuitive — but internally, Git is doing something surprisingly simple.
To truly understand it, I decided to build a tiny, toy version of Git in JavaScript.
Not production-ready. Not complete. Just accurate enough to strip away the magic.
This post walks through:
- how Git commits work internally
- what branches really are
- what
HEADactually points to
The Core Mental Model of Git
Before code, it’s important to simplify Git aggressively.
1. Commits Are Linked Nodes
A commit is nothing more than:
- an
id - a commit
message - a pointer to its
parentcommit
This naturally forms a linked list:
C3 → C2 → C1 → null
There’s no timeline, no array — just pointers.
2. Branches Are Just Pointers
A common misconception is that a branch contains commits.
It doesn’t.
A branch is simply:
{
name: "main",
commit: <latest_commit>
}
That’s it — a named pointer to the latest commit.
3. HEAD Is a Reference to a Branch
HEAD does not point to a commit directly.
It points to the current branch, which then points to a commit:
HEAD → main → C3
Once you internalize this, many Git concepts suddenly click.
Building a Toy Git in JavaScript
Let’s encode this mental model into code.
class Git {
constructor(name) {
this.name = name;
this.lastCommitId = -1;
this.log = [];
this.branch = { name: "main", commit: null };
this.HEAD = this.branch;
this.branches = [this.branch];
}
getRepoInfo() {
return { name: this.name };
}
commit(message) {
const commit = {
id: ++this.lastCommitId,
message,
parent: this.HEAD.commit
};
this.log = [commit, ...this.log];
this.HEAD.commit = commit;
return commit;
}
getLog() {
const log = [];
let currentCommit = this.HEAD.commit;
while (currentCommit) {
log.push(currentCommit);
currentCommit = currentCommit.parent;
}
return log;
}
getCurrentBranch() {
return this.branch;
}
checkout(branchName) {
for (let i = 0; i < this.branches.length; i++) {
if (this.branches[i].name === branchName) {
this.branch = this.branches[i];
this.HEAD = this.branch;
return this;
}
}
this.branch = {
name: branchName,
commit: this.HEAD.commit
};
this.HEAD = this.branch;
this.branches.push(this.branch);
console.log(`Switched to new branch ${branchName}`);
return this;
}
}
This single class captures the essence of Git’s data model.
How Commit History Works
Instead of storing a full history, we walk backward using parent pointers:
getLog() {
const log = [];
let currentCommit = this.HEAD.commit;
while (currentCommit) {
log.push(currentCommit);
currentCommit = currentCommit.parent;
}
return log;
}
This mirrors how Git internally traverses commit history.
Branching and Checkout Explained
When you run:
git checkout test
One of two things happens:
- If the branch exists →
HEADmoves to it - If it doesn’t → Git creates a new branch pointing to the current commit
No commits are copied.
Branches share history until they diverge.
Running the Toy Git
const repo = new Git("test");
repo.commit("Change 0");
repo.commit("Change 1");
console.log(historyToIdMapper(repo.getLog()));
repo.checkout("test");
repo.commit("Change 2");
console.log(historyToIdMapper(repo.getLog()));
repo.checkout("main");
repo.commit("Change 3");
console.log(historyToIdMapper(repo.getLog()));
Output
1-0
2-1-0
3-1-0
This demonstrates:
- shared commit history
- independent branch movement
- how
HEADswitching actually works
What This Clarified for Me
- Commits are nodes, not snapshots in a list
- Branches are movable pointers
HEADis just a reference
Final Thoughts
I’ve found that the fastest way to understand systems is to build toy versions of them.
Small. Incomplete. Honest.
References used writing this blog: Ref1