CORE — Building a GitHub Clone With a Version Control System Written From Scratch
CORE — Building a GitHub Clone With a Version Control System Written From Scratch
What you learn when you stop calling git and start implementing commits, staging, and remotes yourself.
Most "GitHub clone" projects are CRUD apps with a repository table. You create a repo, you upload files, you list them. The interesting part — the part that makes Git Git — is skipped entirely, usually by shelling out to the real git binary.
CORE (Commit Origin & Repository Environment) is my attempt at not skipping it. It's a MERN application with the platform layer (users, repositories, issues, visibility, real-time notifications) and a version control layer implemented from first principles: staging, commits, remotes, and reverts, with no Git dependency anywhere in the code path.
Two Systems Living in One Codebase
CORE is really two products stitched together, and being explicit about that boundary early saved me a lot of pain.
The platform is a conventional REST API: Express 4 on Node, MongoDB through Mongoose, JWT auth, and a React 18 + Vite frontend using Primer (GitHub's own design system) so the UI reads like the thing it's imitating. Requests flow frontend → Axios → Express router → controller → Mongoose model. Nothing exotic, and that's deliberate: the platform should be boring so the version control layer can be interesting.
The version control system is a CLI. Same index.js entry point, but dispatched through yargs:
node index.js init # create the local object store
node index.js add <file> # stage a file
node index.js commit "message" # snapshot the staging area
node index.js push # sync commits to the remote
node index.js pull # restore commits from the remote
node index.js revert <commitID> # restore the working tree
One binary, two entry modes: start boots the HTTP + WebSocket server, everything else runs a version control command and exits.
Designing the Object Store
init creates a hidden directory that mirrors the mental model Git taught everyone:
.apnaGit/
├── staging/ # files queued for the next commit
├── commits/ # one directory per commit, keyed by UUID
│ └── <uuid>/
│ ├── <files>
│ └── commit.json { message, date }
└── config.json # remote configuration
add copies a file from the working directory into staging/. commit generates a UUID, creates commits/<uuid>/, copies everything out of staging into it, and writes commit.json with the message and timestamp.
That's a snapshot store, not a diff store, and it's the first real design decision worth defending.
Git stores content-addressed blobs and reconstructs history from them; snapshots of unchanged files are deduplicated because identical content hashes to the same object. My first version stores whole-file copies per commit, keyed by a random UUID. The trade-off is honest:
- Wins: reverting is a directory copy. Reading a commit needs no graph traversal, no delta chain replay, no packfile format. A commit is browsable with
ls. - Loses: storage grows linearly with commits, not with change. Ten commits touching one file store ten copies of every staged file. And because UUIDs carry no content identity, two identical commits are two different objects.
Knowing why Git chose content addressing is much clearer after building the version that doesn't. The next iteration replaces UUID keys with a SHA of the file contents and stores a tree object per commit that references blobs — at which point dedupe falls out for free and revert becomes a tree walk instead of a copy.
The Remote Is Just Object Storage
push and pull don't talk to a Git server. They talk to S3.
push walks .apnaGit/commits/, and for every file in every commit directory, uploads it to commits/<commitID>/<filename>. pull does the inverse: list every object under the commits/ prefix, recreate the local directory structure, stream each object to disk.
This works because the on-disk layout is the wire format. There's no packing, no negotiation, no "what commits do you already have" handshake. The remote is a dumb mirror of the local object store, and S3's key structure does the job a protocol would otherwise have to do.
The cost shows up immediately at scale: push is O(all files in all commits) every single time, because nothing tracks what the remote already has. The fix is a manifest object — a small JSON file at the remote root listing known commit IDs — so push can diff local against remote and upload only what's missing. That's the "incremental push" feature you get for free with real Git, and it becomes very obvious what it's actually buying you once you're the one paying for it.
revert <commitID> is the bluntest operation in the system: find the commit directory, copy its files over the working directory. It overwrites. There's no merge, no conflict detection, no dirty-tree check. Making that destructive behavior explicit — rather than pretending it's git checkout — is part of the point.
The Platform Layer: Where the Boring Decisions Live
The API side is deliberately conventional, but three choices matter.
Ownership is a reference, not an embed. A Repository holds owner as an ObjectId reference to User, and issues as an array of ObjectId references. Mongoose populate() hydrates them on read. Embedding issues inside the repository document would make reads a single query, but issues are independently mutable and unbounded — exactly the wrong shape for embedding in a document store.
Visibility is a boolean on the repository, toggled by a dedicated endpoint. PATCH /repo/toggle/:id exists instead of overloading the generic update route, because flipping a repo public is a security-relevant operation and it deserves its own audit surface rather than hiding inside a partial update body.
Auth is JWT with bcrypt-hashed passwords, tokens signed at login and signup with a one-hour expiry. This is where the current implementation is deliberately unfinished and I'd rather say so than pretend: the frontend keeps the user ID in localStorage and route guards read it, which means the client can claim an identity even though it can't forge a valid token. The real fix is enforcing ownership server-side on every repository and issue mutation — verifying the JWT subject against the document's owner inside the controller, not trusting a client-supplied ID. That's the next thing I'm hardening, and it's the single most important lesson from the whole project: route placement is not authorization.
Real-Time, Kept Small
Socket.IO sits alongside Express and does one thing: on connection, a client emits joinRoom with its user ID and the socket joins a room named for that user. Server-side events can then be addressed to a single user without broadcasting.
Rooms-per-user is a small idea with a big payoff. It means notifications ("someone opened an issue on your repo") don't need a subscription table, a fan-out worker, or polling. It also means the WebSocket layer stays optional — if the socket never connects, nothing in the REST API breaks. Real-time is an enhancement path, not a dependency.
What This Project Was Actually For
CORE isn't trying to compete with GitHub. It's a deliberate exercise in building the layer everyone abstracts away, because you can't reason about a system you've only ever consumed.
Concretely, implementing add, commit, push, and revert myself changed how I think about four things:
- Content addressing isn't an optimization, it's the whole design. Storage dedupe, integrity checking, and cheap history all fall out of hashing content. Keying commits by UUID gives you none of it.
- A protocol is what you build when the remote can't be dumb. Pushing to S3 works until you need incrementality — then you're inventing negotiation.
- Destructive operations should look destructive.
revertoverwriting the working tree with no dirty check taught me why Git nags before checkout. - The interesting complexity is rarely in the CRUD. The repository controller took an afternoon. The object store took the rest of the project.
If you've only ever used version control, build a small, wrong one. The wrongness is where the learning is.