Build Your Own Git From Scratch — In Node.js + TypeScript

Er. Suman SharmaEr. Suman Sharma

Build Your Own Git From Scratch — In Node.js + TypeScript

The best way to understand something is to build it yourself.

I used Git for two years before I actually understood what git add does. I thought it was "marking" a file. It's not — it computes a SHA-1 hash of the file's contents and writes a compressed object to disk. Every time.

Once I built a tiny version of Git myself, everything clicked. Not just what the commands do, but why they work the way they do. Why branches are cheap. Why you can always go back. Why git reset --hard feels permanent but isn't.

This is that project. We're calling it sgit — Suman Git. By the end, you'll have a working version-control system that can init a repo, stage files, commit snapshots, show history, restore old versions, and manage branches.

Terminal showing sgit commands — init, add, commit, log — on a dark background

What We're Building

Five commands. That's it.

sgit init # create a new repo sgit add index.html # stage a file sgit commit -m "first one" # save a snapshot sgit log # see history sgit checkout <commitId> # restore a past version

And the folder structure it creates:

.sgit/ objects/ ← file contents, stored by hash commits/ ← commit snapshots index.json ← staging area HEAD ← latest commit id

Simple. Honest. Enough to understand the real Git.

Project Setup

  1. Create the project

    mkdir sgit cd sgit npm init -y
  2. Install dependencies

    npm install commander fs-extra chalk npm install -D typescript ts-node @types/node
    • commander — for building the CLI
    • fs-extra — better file system utilities
    • chalk — colored terminal output
    • ts-node — run TypeScript directly without compiling
  3. Set up TypeScript

    npx tsc --init

    Open tsconfig.json and make sure these are set:

    { "compilerOptions": { "target": "ES2020", "module": "commonjs", "rootDir": "./src", "outDir": "./dist", "strict": true, "esModuleInterop": true } }
  4. Create the folder structure

    sgit/ src/ commands/ init.ts add.ts commit.ts log.ts checkout.ts utils/ hash.ts paths.ts index.ts
    mkdir -p src/commands src/utils touch src/index.ts src/commands/init.ts src/commands/add.ts touch src/commands/commit.ts src/commands/log.ts src/commands/checkout.ts touch src/utils/hash.ts src/utils/paths.ts
  5. Add the run script to package.json

    { "scripts": { "dev": "ts-node src/index.ts" } }

The Utilities — Hash and Paths

Before any command, we need two small utilities that everything else depends on.

src/utils/hash.ts

This is the core of everything. A hash function takes any content and returns a fixed-length unique string. Same content always returns the same hash. Change one character and the hash is completely different.

import crypto from "crypto"; export function hashContent(content: string): string { return crypto.createHash("sha1").update(content).digest("hex"); }

src/utils/paths.ts

One file to hold all our folder paths so we're never hardcoding strings everywhere.

import path from "path"; export const SGIT_DIR = ".sgit"; export const OBJECTS_DIR = path.join(SGIT_DIR, "objects"); export const COMMITS_DIR = path.join(SGIT_DIR, "commits"); export const INDEX_FILE = path.join(SGIT_DIR, "index.json"); export const HEAD_FILE = path.join(SGIT_DIR, "HEAD");

Phase 1 — sgit init

The simplest command. Creates the hidden .sgit folder and its structure.

src/commands/init.ts

import fs from "fs-extra"; import chalk from "chalk"; import { OBJECTS_DIR, COMMITS_DIR, INDEX_FILE, HEAD_FILE } from "../utils/paths"; export async function init() { await fs.ensureDir(OBJECTS_DIR); await fs.ensureDir(COMMITS_DIR); await fs.writeJson(INDEX_FILE, {}); await fs.writeFile(HEAD_FILE, ""); console.log(chalk.green("✓ Initialized empty sgit repository")); }

What this creates

.sgit/ objects/ ← empty, will hold file content by hash commits/ ← empty, will hold commit snapshots index.json ← {}, the staging area starts empty HEAD ← empty string, no commits yet

Phase 2 — sgit add <file>

This is where hashing becomes real.

When you run sgit add index.html, the tool should:

  1. Read the file's content
  2. Hash that content with SHA-1
  3. Save the content inside .sgit/objects/<hash>
  4. Record { "index.html": "<hash>" } in index.json

src/commands/add.ts

import fs from "fs-extra"; import path from "path"; import chalk from "chalk"; import { hashContent } from "../utils/hash"; import { OBJECTS_DIR, INDEX_FILE } from "../utils/paths"; export async function add(filepath: string) { // Does the file exist? if (!await fs.pathExists(filepath)) { console.log(chalk.red(`✗ File not found: ${filepath}`)); return; } // Read the file content const content = await fs.readFile(filepath, "utf-8"); // Hash the content const hash = hashContent(content); // Save content in objects/ using hash as filename const objectPath = path.join(OBJECTS_DIR, hash); await fs.writeFile(objectPath, content); // Update index.json with file path → hash mapping const index = await fs.readJson(INDEX_FILE); index[filepath] = hash; await fs.writeJson(INDEX_FILE, index, { spaces: 2 }); console.log(chalk.green(`✓ Added ${filepath} (${hash.slice(0, 7)})`)); }

What happens on disk

After sgit add index.html:

.sgit/ objects/ a3f8c12e... ← the actual content of index.html index.json ← { "index.html": "a3f8c12e..." }

Phase 3 — sgit commit -m "message"

A commit is a snapshot. It records:

  • A unique ID (hash of the commit itself)
  • The message
  • A timestamp
  • The current staging area (files + their hashes)
  • The parent commit's ID

What a commit object looks like

{ "id": "b4e91f3a...", "message": "first commit", "timestamp": "2026-05-19T10:30:00.000Z", "parent": "", "files": { "index.html": "a3f8c12e...", "style.css": "d7b22a91..." } }

The parent field is the chain. Each commit points back to the previous one. That's how history works.

src/commands/commit.ts

import fs from "fs-extra"; import path from "path"; import chalk from "chalk"; import { hashContent } from "../utils/hash"; import { COMMITS_DIR, INDEX_FILE, HEAD_FILE } from "../utils/paths"; export async function commit(message: string) { // Read the staging area const index = await fs.readJson(INDEX_FILE); if (Object.keys(index).length === 0) { console.log(chalk.yellow("Nothing staged. Run sgit add <file> first.")); return; } // Get current HEAD (parent commit) const parent = (await fs.readFile(HEAD_FILE, "utf-8")).trim(); // Build the commit object const commitData = { id: "", message, timestamp: new Date().toISOString(), parent, files: index, }; // Hash the commit content to get its unique ID const commitHash = hashContent(JSON.stringify(commitData)); commitData.id = commitHash; // Save commit to .sgit/commits/<hash>.json const commitPath = path.join(COMMITS_DIR, `${commitHash}.json`); await fs.writeJson(commitPath, commitData, { spaces: 2 }); // Update HEAD to point to this new commit await fs.writeFile(HEAD_FILE, commitHash); // Clear the staging area await fs.writeJson(INDEX_FILE, {}); console.log(chalk.green(`✓ Commit ${commitHash.slice(0, 7)} — "${message}"`)); }

What happens on disk

After sgit commit -m "first commit":

.sgit/ commits/ b4e91f3a....json ← the commit snapshot HEAD ← b4e91f3a... index.json ← {} (cleared after commit)

Phase 4 — sgit log

Read HEAD. Load that commit. Print it. Follow parent. Repeat until there's no parent.

src/commands/log.ts

import fs from "fs-extra"; import path from "path"; import chalk from "chalk"; import { COMMITS_DIR, HEAD_FILE } from "../utils/paths"; export async function log() { let currentHash = (await fs.readFile(HEAD_FILE, "utf-8")).trim(); if (!currentHash) { console.log(chalk.yellow("No commits yet.")); return; } while (currentHash) { const commitPath = path.join(COMMITS_DIR, `${currentHash}.json`); const commit = await fs.readJson(commitPath); console.log(chalk.yellow(`commit ${commit.id.slice(0, 7)}`)); console.log(`Date: ${commit.timestamp}`); console.log(`Message: ${commit.message}`); console.log(`Files: ${Object.keys(commit.files).join(", ")}`); console.log("---"); currentHash = commit.parent; } }

Output

commit b4e91f3 Date: 2026-05-19T10:30:00.000Z Message: second commit Files: index.html, style.css --- commit a1c3d7f Date: 2026-05-19T09:15:00.000Z Message: first commit Files: index.html ---

Phase 5 — sgit checkout <commitId>

Load a past commit. Look at its files map. For each file, find the content in objects/ by hash and write it back to disk.

src/commands/checkout.ts

import fs from "fs-extra"; import path from "path"; import chalk from "chalk"; import { COMMITS_DIR, OBJECTS_DIR, HEAD_FILE } from "../utils/paths"; export async function checkout(commitId: string) { const commitPath = path.join(COMMITS_DIR, `${commitId}.json`); if (!await fs.pathExists(commitPath)) { console.log(chalk.red(`✗ Commit not found: ${commitId}`)); return; } const commit = await fs.readJson(commitPath); // Restore each file from objects/ for (const [filepath, hash] of Object.entries(commit.files)) { const objectPath = path.join(OBJECTS_DIR, hash as string); const content = await fs.readFile(objectPath, "utf-8"); // Make sure parent directories exist await fs.ensureDir(path.dirname(filepath)); await fs.writeFile(filepath, content); console.log(chalk.green(`✓ Restored ${filepath}`)); } // Move HEAD to this commit await fs.writeFile(HEAD_FILE, commitId); console.log(chalk.cyan(`\nNow at commit ${commitId.slice(0, 7)} — "${commit.message}"`)); }

The CLI Entry Point

Wire everything together with commander.

src/index.ts

import { Command } from "commander"; import { init } from "./commands/init"; import { add } from "./commands/add"; import { commit } from "./commands/commit"; import { log } from "./commands/log"; import { checkout } from "./commands/checkout"; const program = new Command(); program .name("sgit") .description("A mini Git built from scratch") .version("1.0.0"); program .command("init") .description("Initialize a new repository") .action(init); program .command("add <file>") .description("Stage a file") .action(add); program .command("commit") .description("Commit staged files") .option("-m, --message <message>", "Commit message") .action((options) => commit(options.message)); program .command("log") .description("Show commit history") .action(log); program .command("checkout <commitId>") .description("Restore files from a past commit") .action(checkout); program.parse(process.argv);

Try It Out

# Initialize npx ts-node src/index.ts init # Create some files echo "<h1>Hello</h1>" > index.html echo "body { margin: 0; }" > style.css # Stage them npx ts-node src/index.ts add index.html npx ts-node src/index.ts add style.css # Commit npx ts-node src/index.ts commit -m "first commit" # Make a change echo "<h1>Hello World</h1>" > index.html npx ts-node src/index.ts add index.html npx ts-node src/index.ts commit -m "update heading" # See history npx ts-node src/index.ts log # Go back in time npx ts-node src/index.ts checkout <first-commit-id> cat index.html # prints: <h1>Hello</h1>

It works. You just built a version control system.

Phase 6 — Branches (Bonus)

Branches in real Git are just files containing a commit hash. We do the same thing.

Create .sgit/refs/ and store one file per branch:

.sgit/ refs/ main ← contains a commit hash dev ← contains a different commit hash

src/commands/branch.ts

import fs from "fs-extra"; import path from "path"; import chalk from "chalk"; import { HEAD_FILE } from "../utils/paths"; const REFS_DIR = ".sgit/refs"; export async function branch(name: string) { await fs.ensureDir(REFS_DIR); // New branch points to current HEAD const currentHead = (await fs.readFile(HEAD_FILE, "utf-8")).trim(); const branchPath = path.join(REFS_DIR, name); await fs.writeFile(branchPath, currentHead); console.log(chalk.green(`✓ Created branch "${name}" at ${currentHead.slice(0, 7)}`)); } export async function listBranches() { await fs.ensureDir(REFS_DIR); const branches = await fs.readdir(REFS_DIR); if (branches.length === 0) { console.log(chalk.yellow("No branches yet.")); return; } for (const b of branches) { const hash = (await fs.readFile(path.join(REFS_DIR, b), "utf-8")).trim(); console.log(` ${b} → ${hash.slice(0, 7)}`); } }

A branch is a file. Creating one is creating a file. Switching branches is updating HEAD to point at that file's commit. This is exactly what real Git does.

What You Just Built vs Real Git

FeaturesgitReal Git
Content-addressed storage✓ SHA-1 hashes✓ SHA-1 (now SHA-256)
Staging areaindex.json✓ binary .git/index
Commit chain via parent
Branches as ref files
Checkout / restore
Compression✗ plain text✓ zlib + packfiles
Trees (directory objects)✗ flat files only✓ recursive tree objects
Merge + conflict resolution
Remote push/pull
Diff algorithm✓ Myers diff

The fundamentals are identical. The real Git just adds compression, binary formats, and a lot of edge cases.

What to Build Next

You've got a working base. Here's how to keep going:

Diff command — compare two commits by loading their files map and printing what changed.

Merge — load two commits, find files that differ, apply both changes, flag conflicts.

.sgitignore — skip files matching patterns when running sgit add .

Compression — use Node's zlib module to compress objects before writing, just like real Git.

Tree objects — instead of a flat files map in commits, build nested tree objects for directory support.

Each one teaches you something real Git solves. The solutions are all in the Git source code — which you can now actually read and follow, because you understand the shape of the problem.

The Full Project on GitHub

The complete source for sgit — all phases, fully typed, with a README — is at:

github.com/iamsumansharma/sgit ← update this with your actual link

The best debugger is building the thing yourself. You'll never misuse git reset again after you've written your own version of it.

Contact

Get in Touch

Want to call me? book a call and I'll respond in the available time slot. I will ignore all soliciting.

GitHub
LinkedIn
youtube