DEV Community

Ali Shirani
Ali Shirani

Posted on

I Built Git in JavaScript… and You Can Too! (Step-by-Step Tutorial)

You type git commit a dozen times a day—but what actually happens when you hit Enter?

What if I told you that you could domesticate version control forever—by building your own modern Git clone from scratch?

Meet GITTY—a simplified, beautiful Git clone we’re going to build together using Node.js. By the end of this journey, you won’t just use Git—you’ll finally understand how it works under the hood.

Oh, and bonus? You’ll have an incredible project for your portfolio.

Let’s dive in.


🛠️ Setting Up Our Project

First things first—let’s initialize our Node.js project:

mkdir gitty && cd gitty  
npm init -y  
Enter fullscreen mode Exit fullscreen mode

The key detail here? Setting "type": "module" in package.json so we can use modern ES6 imports.

Installing Dependencies

We’ll need a few tools to make our CLI sleek and interactive:

  • Commander – For a professional command structure.
  • Inquirer – For beautiful interactive prompts.
  • Chalk & Figlet – For some CLI flair.
npm install commander inquirer chalk figlet  
Enter fullscreen mode Exit fullscreen mode

Project Structure

We’ll keep things clean with:

  • gitty.js (main executable)
  • commands/ (for modular logic)

In package.json, we add a "bin" field to make gitty.js executable:

"bin": {
  "gy": "./gitty.js"
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s scaffold our CLI in gitty.js:

import { program } from 'commander';
import figlet from 'figlet';
import chalk from 'chalk';

console.log(chalk.blue(figlet.textSync('GITTY')));

program
  .version('1.0.0')
  .description('A beautiful Git clone built with Node.js')
  .command('init', 'Initialize a new GITTY repository')
  .command('add', 'Stage files for commit')
  .command('commit', 'Create a new commit');

program.parse();
Enter fullscreen mode Exit fullscreen mode

With this skeleton in place, we’re ready to build our first command.


🔨 Building gitty init – The Brain of Our System

The goal? Create a .gitty directory—our version control database.

Inside commands/init.js:

import fs from 'fs';
import path from 'path';
import chalk from 'chalk';

const init = () => {
  if (fs.existsSync('.gitty')) {
    console.log(chalk.red('GITTY repository already exists!'));
    process.exit(1);
  }

  // Create essential directories
  fs.mkdirSync('.gitty');
  fs.mkdirSync('.gitty/objects'); // Stores blobs, trees, commits
  fs.mkdirSync('.gitty/refs');    // Stores branch pointers

  // Initialize HEAD (points to current branch)
  fs.writeFileSync('.gitty/HEAD', 'ref: refs/heads/main');

  console.log(chalk.green('GITTY repository initialized!'));
};

export default init;
Enter fullscreen mode Exit fullscreen mode

What’s happening?

  • .gitty/objects – Stores all file data (blobs) and commits.
  • .gitty/refs – Holds branch references (like main).
  • .gitty/HEAD – Tracks the currently checked-out branch.

Now, running gitty init sets up our version control brain.


📦 Implementing gitty add – Staging Files Like a Pro

Git doesn’t store duplicate files—it uses content-addressable storage.

When you add a file:

  1. Its contents are hashed (SHA-1).
  2. The hash becomes the filename inside .gitty/objects.
  3. The mapping (filename → hash) is stored in the index.

Here’s how we do it:

import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import chalk from 'chalk';

const add = (files) => {
  const gyDir = path.resolve('.gitty');
  const indexFile = path.join(gyDir, 'index');

  let index = {};
  if (fs.existsSync(indexFile)) {
    index = JSON.parse(fs.readFileSync(indexFile));
  }

  files.forEach((file) => {
    const content = fs.readFileSync(file);
    const hash = crypto.createHash('sha1').update(content).digest('hex');

    // Save blob to objects
    fs.writeFileSync(path.join(gittyDir, 'objects', hash), content);

    // Update index
    index[file] = hash;
  });

  fs.writeFileSync(indexFile, JSON.stringify(index));
  console.log(chalk.green('Files staged!'));
};

export default add;
Enter fullscreen mode Exit fullscreen mode

Now, gitty add file.txt stores the file forever in .gitty/objects.


💾 The Magic of gitty commit – Snapshotting History

A commit is a permanent snapshot with:

Author & timestamp

Commit message

Tree hash (directory state)

Parent hash (previous commit)

Here’s how we build it:

import inquirer from 'inquirer';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import chalk from 'chalk';

const commit = async () => {
  const { message } = await inquirer.prompt({
    type: 'input',
    name: 'message',
    message: 'Enter commit message:',
  });

  const gittyDir = path.resolve('.gitty');
  const indexFile = path.join(gittyDir, 'index');

  // 1. Build the tree object (represents directory structure)
  const index = JSON.parse(fs.readFileSync(indexFile));
  const tree = { files: index };
  const treeHash = crypto.createHash('sha1').update(JSON.stringify(tree)).digest('hex');
  fs.writeFileSync(path.join(gittyDir, 'objects', treeHash), JSON.stringify(tree));

  // 2. Find parent commit (if exists)
  let parentHash = null;
  const headRef = fs.readFileSync(path.join(gittyDir, 'HEAD'), 'utf-8').trim();
  const branchFile = path.join(gittyDir, headRef.split(': ')[1]);

  if (fs.existsSync(branchFile)) {
    parentHash = fs.readFileSync(branchFile, 'utf-8').trim();
  }

  // 3. Create commit object
  const commit = {
    tree: treeHash,
    parent: parentHash,
    author: 'Your Name <[email protected]>',
    message,
    timestamp: new Date().toISOString(),
  };

  const commitHash = crypto.createHash('sha1').update(JSON.stringify(commit)).digest('hex');
  fs.writeFileSync(path.join(gittyDir, 'objects', commitHash), JSON.stringify(commit));

  // 4. Update branch pointer
  fs.writeFileSync(branchFile, commitHash);

  console.log(chalk.green(`Commit ${commitHash.slice(0, 7)} created!`));
};

export default commit;
Enter fullscreen mode Exit fullscreen mode

Now, gitty commit captures history forever.


🎉 The Grand Finale

Let’s test it:

npm link       # Make `gitty` globally available
gitty init        # Initialize repo
gitty add file.txt # Stage a file
gitty commit      # Create a commit
Enter fullscreen mode Exit fullscreen mode

Boom! You’ve just built a functional Git clone.


🔍 Key Takeaways

Blobs – Hashed file contents.

Trees – Snapshots of directories.

Commits – Immutable history points.

Branches – Just pointers to commits.

Now, you understand Git deeply—not just use it.


🚀 What’s Next?

Want to extend Gitty? Try:

  • Branching (gitty branch)
  • Checking out commits (gitty checkout)
  • Remote repositories (gitty remote)

What feature should we add next? Drop a comment below!

If you enjoyed this deep dive, smash that like button, subscribe, and share with fellow devs.

Happy coding! 🚀

Top comments (2)

Collapse
 
nevodavid profile image
Nevo David

pretty cool seeing someone break it down like this - def makes stuff click way easier for me. you ever find building something from scratch teaches you stuff tutorials just never hit?

Collapse
 
alishirani profile image
Ali Shirani

Yes for me building something from scratch and the way of thinking to create it with the new stacks and packages gets me so hyped and lots of learning happens in the process.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.