Build a Version Control System in Java

Reimplement Git's core engine in pure Java with SHA-256 hashing.

Introduction

30 Second Summary

Every time you hit save on a file, the old version is gone. Version control is the system that keeps every previous version safe, letting you rewind to any point in history.

In this project, you will build MiniGit, a command-line version control system written in pure Java. MiniGit mirrors how Git actually works under the hood, using content hashing to store snapshots, track changes, and display history.

What You'll Build

MiniGit is a CLI tool that reimplements Git's core engine in pure Java, using SHA-256 content hashing to store file snapshots in a content-addressable object store.

By the end of this project, you'll have:

  • A content-addressable object store that uses SHA-256 hashing to store and deduplicate file snapshots, mirroring Git's real internal architecture.
  • A complete CLI workflow with init, add, status, commit, and log commands that you run from your terminal.
  • A .minigitignore system that filters files from staging using exact names, wildcard extensions, and directory patterns.
  • Secret Mission: Add a checkout command that restores files from any previous commit.

Are there any prerequisites?

This project assumes you are comfortable with Java basics like classes, collections, and file I/O. No external libraries or build tools are needed.

Set Up the Project

Before writing any code, you need a working Java compiler and a project folder structured for the minigit package.

MiniGit is a pure Java project with zero external dependencies. The JDK (Java Development Kit) is all you need.

In this step, get ready to:
  • Verify JDK 17 is ready in your terminal.
  • Confirm the minigit package folder structure.
Verify your JDK installation

MiniGit needs both the Java compiler (javac) to turn your source files into bytecode and the Java runtime (java) to execute them.

  • In VS Code, click Terminal in the top menu bar.
  • Click New Terminal.
  • Verify your Java runtime and compiler by running these commands:
java -version
javac -version

Both commands should display a version number that starts with 17.

What do these commands do?

  • java -version checks the Java runtime, which executes your compiled programs.
  • javac -version checks the Java compiler, which turns your .java source files into bytecode the runtime can execute.

javac not recognized?

If your terminal recognizes java but not javac, your JDK's bin directory may not be in your system PATH. Make sure you installed the full JDK, not just the JRE.

Help me add the JDK bin directory to my system PATH

Confirm your project structure

Your minigit-project folder contains a minigit subfolder. This is the Java package directory where all your source files will live.

  • Confirm the folder exists by running:
ls

You should see minigit listed in the output.

Why does the folder name matter?

In Java, the package name in your source code must match the directory structure on disk. Every file you write will start with package minigit;, which tells the compiler to look for source files inside a folder called minigit.

You will always compile and run commands from the parent minigit-project directory, not from inside minigit.

Your environment is all set. Next up, you'll build the SHA-256 hashing utility, object data model, and init command that form MiniGit's core engine.

Build the Object Store and Init Command

Your project folder is set up and your JDK is confirmed. Now it's time to write the engine that powers MiniGit.

Real Git stores every file snapshot as an object identified by a hash of its content. MiniGit mirrors this architecture using SHA-256 hashing, Java serialization, and a set of data model classes that represent Git's four internal object types.

In this step, get ready to:
  • Build a SHA-256 hashing utility and object serialization layer.
  • Create data model classes for blobs, trees, commits, and the staging index.
  • Wire up the init command and run it from the terminal.
Create the utility classes

Every object in MiniGit gets a unique identity from its content. The SHA-256 algorithm takes any byte array and produces a 64-character hex string that changes completely if even a single byte differs.

  • Create a new file called Utils.java inside the minigit folder.
  • Paste the following code into Utils.java:
package minigit;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Utils {

    public static String sha256(byte[] content) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(content);
            StringBuilder hex = new StringBuilder();
            for (byte b : hash) {
                hex.append(String.format("%02x", b));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 not available", e);
        }
    }

    public static void serialize(Serializable obj, Path path) throws IOException {
        Files.createDirectories(path.getParent());
        try (ObjectOutputStream out = new ObjectOutputStream(
                new FileOutputStream(path.toFile()))) {
            out.writeObject(obj);
        }
    }

    public static Object deserialize(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream in = new ObjectInputStream(
                new FileInputStream(path.toFile()))) {
            return in.readObject();
        }
    }
}

What does this code do?

This utility class has two core capabilities:

  • sha256() takes a byte array, feeds it to Java's built-in MessageDigest, and returns a 64-character hex string. Two identical byte arrays always produce the same hash.
  • serialize() and deserialize() persist any Serializable object to disk and read it back. These are the read/write layer for every object MiniGit stores.

What is content-addressable storage?

Content-addressable storage means an object's address (where it lives on disk) is determined by its content, not by a name you choose. If two files have identical content, they produce the same hash and share the same storage slot. This gives you automatic deduplication for free.

  • Save Utils.java.

Next, you'll create four data model classes that mirror Git's internal object types. Each class implements Serializable so it can be persisted to disk using the utility methods you just wrote.

  • Create a new file called Blob.java inside the minigit folder.
  • Paste the following code into Blob.java:
package minigit;

import java.io.Serializable;

public class Blob implements Serializable {
    private static final long serialVersionUID = 1L;

    private final byte[] content;

    public Blob(byte[] content) {
        this.content = content;
    }

    public byte[] getContent() {
        return content;
    }
}

What is a blob?

A blob is the simplest object type in Git. It stores raw file content as a byte array with no filename or metadata attached. The hash of that byte array becomes the blob's address in the object store.

  • Save Blob.java.
  • Create a new file called Tree.java inside the minigit folder.
  • Paste the following code into Tree.java:
package minigit;

import java.io.Serializable;
import java.util.Map;
import java.util.TreeMap;

public class Tree implements Serializable {
    private static final long serialVersionUID = 1L;

    private final Map<String, String> entries;

    public Tree(Map<String, String> entries) {
        this.entries = new TreeMap<>(entries);
    }

    public Map<String, String> getEntries() {
        return entries;
    }

    public String computeHash() {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : entries.entrySet()) {
            sb.append(entry.getKey()).append(" ").append(entry.getValue()).append("\n");
        }
        return Utils.sha256(sb.toString().getBytes());
    }
}

What is a tree?

A tree represents a directory snapshot. It maps relative file paths (like src/App.java) to blob hashes. The computeHash() method sorts these entries alphabetically and hashes the combined result, so two trees with the same files always get the same hash.

  • Save Tree.java.
  • Create a new file called Commit.java inside the minigit folder.
  • Paste the following code into Commit.java:
package minigit;

import java.io.Serializable;
import java.time.Instant;

public class Commit implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String treeHash;
    private final String parentHash;
    private final String message;
    private final String timestamp;

    public Commit(String treeHash, String parentHash, String message) {
        this.treeHash = treeHash;
        this.parentHash = parentHash;
        this.message = message;
        this.timestamp = Instant.now().toString();
    }

    public String getTreeHash() {
        return treeHash;
    }

    public String getParentHash() {
        return parentHash;
    }

    public String getMessage() {
        return message;
    }

    public String getTimestamp() {
        return timestamp;
    }

    public String computeHash() {
        String data = "tree " + treeHash + "\n"
                + (parentHash != null ? "parent " + parentHash + "\n" : "")
                + "timestamp " + timestamp + "\n"
                + "message " + message + "\n";
        return Utils.sha256(data.getBytes());
    }
}

What is a commit?

A commit ties everything together. It stores a tree hash (the directory snapshot), a parent commit hash (forming a chain of history), a message, and a timestamp.

The parentHash is null for the very first commit. Every commit after that points back to its parent, creating a linked list of history.

  • Save Commit.java.
  • Create a new file called Index.java inside the minigit folder.
  • Paste the following code into Index.java:
package minigit;

import java.io.Serializable;
import java.util.Map;
import java.util.TreeMap;

public class Index implements Serializable {
    private static final long serialVersionUID = 1L;

    private final Map<String, String> entries;

    public Index() {
        this.entries = new TreeMap<>();
    }

    public void add(String path, String blobHash) {
        entries.put(path, blobHash);
    }

    public Map<String, String> getEntries() {
        return entries;
    }

    public boolean isEmpty() {
        return entries.isEmpty();
    }
}

What is the index?

The index is the staging area. When you run add, a file's path and blob hash get recorded here. When you run commit, the index becomes the tree snapshot for that commit. This is exactly how real Git's staging area works.

  • Save Index.java.

You won't be able to run any of these files yet. The next substep creates Repository.java which brings them all together.

Seeing red squiggles in VS Code?

VS Code may show errors in files like Tree.java or Commit.java because they reference Utils.sha256() which lives in a different file. These errors disappear once you compile all files together in the final substep.

Make sure each file starts with package minigit; on the first line. Missing the package declaration causes compilation to fail.

Why are my Java files showing errors before I compile?

✔️ Awesome, I've got everything!

Great. Double check you've saved all five files before moving on.

ⓧ I'd like to double check the full code

Here are all five files. Compare yours against these:

package minigit;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Utils {

    public static String sha256(byte[] content) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] hash = md.digest(content);
            StringBuilder hex = new StringBuilder();
            for (byte b : hash) {
                hex.append(String.format("%02x", b));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 not available", e);
        }
    }

    public static void serialize(Serializable obj, Path path) throws IOException {
        Files.createDirectories(path.getParent());
        try (ObjectOutputStream out = new ObjectOutputStream(
                new FileOutputStream(path.toFile()))) {
            out.writeObject(obj);
        }
    }

    public static Object deserialize(Path path) throws IOException, ClassNotFoundException {
        try (ObjectInputStream in = new ObjectInputStream(
                new FileInputStream(path.toFile()))) {
            return in.readObject();
        }
    }
}
package minigit;

import java.io.Serializable;

public class Blob implements Serializable {
    private static final long serialVersionUID = 1L;

    private final byte[] content;

    public Blob(byte[] content) {
        this.content = content;
    }

    public byte[] getContent() {
        return content;
    }
}
package minigit;

import java.io.Serializable;
import java.util.Map;
import java.util.TreeMap;

public class Tree implements Serializable {
    private static final long serialVersionUID = 1L;

    private final Map<String, String> entries;

    public Tree(Map<String, String> entries) {
        this.entries = new TreeMap<>(entries);
    }

    public Map<String, String> getEntries() {
        return entries;
    }

    public String computeHash() {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : entries.entrySet()) {
            sb.append(entry.getKey()).append(" ").append(entry.getValue()).append("\n");
        }
        return Utils.sha256(sb.toString().getBytes());
    }
}
package minigit;

import java.io.Serializable;
import java.time.Instant;

public class Commit implements Serializable {
    private static final long serialVersionUID = 1L;

    private final String treeHash;
    private final String parentHash;
    private final String message;
    private final String timestamp;

    public Commit(String treeHash, String parentHash, String message) {
        this.treeHash = treeHash;
        this.parentHash = parentHash;
        this.message = message;
        this.timestamp = Instant.now().toString();
    }

    public String getTreeHash() {
        return treeHash;
    }

    public String getParentHash() {
        return parentHash;
    }

    public String getMessage() {
        return message;
    }

    public String getTimestamp() {
        return timestamp;
    }

    public String computeHash() {
        String data = "tree " + treeHash + "\n"
                + (parentHash != null ? "parent " + parentHash + "\n" : "")
                + "timestamp " + timestamp + "\n"
                + "message " + message + "\n";
        return Utils.sha256(data.getBytes());
    }
}
package minigit;

import java.io.Serializable;
import java.util.Map;
import java.util.TreeMap;

public class Index implements Serializable {
    private static final long serialVersionUID = 1L;

    private final Map<String, String> entries;

    public Index() {
        this.entries = new TreeMap<>();
    }

    public void add(String path, String blobHash) {
        entries.put(path, blobHash);
    }

    public Map<String, String> getEntries() {
        return entries;
    }

    public boolean isEmpty() {
        return entries.isEmpty();
    }
}
Build the repository manager

The Repository class is where MiniGit's commands live. It manages the .minigit/ directory, stores and retrieves objects, and tracks the current commit through a HEAD file.

  • Create a new file called Repository.java inside the minigit folder.
  • Paste the following code into Repository.java:
package minigit;

import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Stream;

public class Repository {

    private static final Path MINIGIT_DIR = Path.of(".minigit");
    private static final Path OBJECTS_DIR = MINIGIT_DIR.resolve("objects");
    private static final Path REFS_DIR = MINIGIT_DIR.resolve("refs");
    private static final Path HEAD_FILE = MINIGIT_DIR.resolve("HEAD");
    private static final Path INDEX_FILE = MINIGIT_DIR.resolve("index");
    private static final Path IGNORE_FILE = Path.of(".minigitignore");

    public static void init() {
        if (Files.exists(MINIGIT_DIR)) {
            System.out.println("MiniGit repository already exists.");
            return;
        }
        try {
            Files.createDirectories(OBJECTS_DIR);
            Files.createDirectories(REFS_DIR);
            Files.writeString(HEAD_FILE, "");
            Utils.serialize(new Index(), INDEX_FILE);
            System.out.println("Initialized empty MiniGit repository in .minigit/");
        } catch (IOException e) {
            System.out.println("Error initializing repository: " + e.getMessage());
        }
    }

    private static void saveObject(String hash, Serializable obj) throws IOException {
        Path dir = OBJECTS_DIR.resolve(hash.substring(0, 2));
        Path file = dir.resolve(hash.substring(2));
        if (!Files.exists(file)) {
            Utils.serialize(obj, file);
        }
    }

    private static Object loadObject(String hash) throws IOException, ClassNotFoundException {
        Path file = OBJECTS_DIR.resolve(hash.substring(0, 2)).resolve(hash.substring(2));
        return Utils.deserialize(file);
    }

    private static String getHead() throws IOException {
        if (!Files.exists(HEAD_FILE)) {
            return null;
        }
        String head = Files.readString(HEAD_FILE).trim();
        return head.isEmpty() ? null : head;
    }

    private static void setHead(String commitHash) throws IOException {
        Files.writeString(HEAD_FILE, commitHash);
    }

    private static Index loadIndex() {
        try {
            if (Files.exists(INDEX_FILE) && Files.size(INDEX_FILE) > 0) {
                return (Index) Utils.deserialize(INDEX_FILE);
            }
        } catch (IOException | ClassNotFoundException e) {
            // Return fresh index if loading fails
        }
        return new Index();
    }

    private static void checkRepo() {
        if (!Files.exists(MINIGIT_DIR)) {
            System.out.println("Not a MiniGit repository. Run 'minigit init' first.");
            System.exit(1);
        }
    }
}

What does this code do?

This file has two sections:

  • The init() method creates the .minigit/ directory structure: objects/ for the content-addressable store, refs/ (prepared for future branch support), a HEAD file to track the latest commit, and a serialized empty index for staging.
  • The private helper methods handle the plumbing. saveObject() stores an object at .minigit/objects/<first 2 hash chars>/<remaining chars>, mirroring Git's directory-sharded layout. loadObject() reads objects back. checkRepo() exits early if no repository has been initialized.

Why shard objects into subdirectories?

Storing thousands of files in a single directory slows down file lookups on most operating systems. Splitting by the first two characters of the hash creates up to 256 subdirectories, keeping each one small. This is the same strategy real Git uses.

  • Save Repository.java.

You won't see a change yet. The next substep creates the CLI entry point so you can compile and run the init command.

Seeing import errors?

Some of the imports at the top of Repository.java (like ArrayList and Stream) are not used yet. They are prepared for commands you will add in later steps. VS Code may show warnings about unused imports. This is safe to ignore.

Why does Repository.java import classes it doesn't use yet?

✔️ Awesome, I've got everything!

Great. Double check you've saved Repository.java before moving on.

ⓧ I'd like to double check the full code

Compare your Repository.java against this:

package minigit;

import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Stream;

public class Repository {

    private static final Path MINIGIT_DIR = Path.of(".minigit");
    private static final Path OBJECTS_DIR = MINIGIT_DIR.resolve("objects");
    private static final Path REFS_DIR = MINIGIT_DIR.resolve("refs");
    private static final Path HEAD_FILE = MINIGIT_DIR.resolve("HEAD");
    private static final Path INDEX_FILE = MINIGIT_DIR.resolve("index");
    private static final Path IGNORE_FILE = Path.of(".minigitignore");

    public static void init() {
        if (Files.exists(MINIGIT_DIR)) {
            System.out.println("MiniGit repository already exists.");
            return;
        }
        try {
            Files.createDirectories(OBJECTS_DIR);
            Files.createDirectories(REFS_DIR);
            Files.writeString(HEAD_FILE, "");
            Utils.serialize(new Index(), INDEX_FILE);
            System.out.println("Initialized empty MiniGit repository in .minigit/");
        } catch (IOException e) {
            System.out.println("Error initializing repository: " + e.getMessage());
        }
    }

    private static void saveObject(String hash, Serializable obj) throws IOException {
        Path dir = OBJECTS_DIR.resolve(hash.substring(0, 2));
        Path file = dir.resolve(hash.substring(2));
        if (!Files.exists(file)) {
            Utils.serialize(obj, file);
        }
    }

    private static Object loadObject(String hash) throws IOException, ClassNotFoundException {
        Path file = OBJECTS_DIR.resolve(hash.substring(0, 2)).resolve(hash.substring(2));
        return Utils.deserialize(file);
    }

    private static String getHead() throws IOException {
        if (!Files.exists(HEAD_FILE)) {
            return null;
        }
        String head = Files.readString(HEAD_FILE).trim();
        return head.isEmpty() ? null : head;
    }

    private static void setHead(String commitHash) throws IOException {
        Files.writeString(HEAD_FILE, commitHash);
    }

    private static Index loadIndex() {
        try {
            if (Files.exists(INDEX_FILE) && Files.size(INDEX_FILE) > 0) {
                return (Index) Utils.deserialize(INDEX_FILE);
            }
        } catch (IOException | ClassNotFoundException e) {
            // Return fresh index if loading fails
        }
        return new Index();
    }

    private static void checkRepo() {
        if (!Files.exists(MINIGIT_DIR)) {
            System.out.println("Not a MiniGit repository. Run 'minigit init' first.");
            System.exit(1);
        }
    }
}
Create the CLI entry point

MiniGit needs a single entry point that reads the command name from the terminal arguments and dispatches to the right method in Repository.

  • Create a new file called Main.java inside the minigit folder.
  • Paste the following code into Main.java:
package minigit;

public class Main {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Usage: java minigit.Main <command> [args]");
            System.out.println("Commands: init, add, status, commit, log");
            return;
        }

        String command = args[0];

        switch (command) {
            case "init":
                Repository.init();
                break;
            case "add":
                if (args.length < 2) {
                    System.out.println("Usage: java minigit.Main add <file>");
                    return;
                }
                Repository.add(args[1]);
                break;
            case "status":
                Repository.status();
                break;
            case "commit":
                if (args.length < 3 || !args[1].equals("-m")) {
                    System.out.println("Usage: java minigit.Main commit -m \"message\"");
                    return;
                }
                Repository.commit(args[2]);
                break;
            case "log":
                Repository.log();
                break;
            default:
                System.out.println("Unknown command: " + command);
        }
    }
}

What does this code do?

The main method reads args[0] as the command name and uses a switch statement to dispatch to the matching method in Repository. Right now only init is implemented. The other cases (add, status, commit, log) will compile but produce errors at runtime until you implement them in later steps.

  • Save Main.java.

Now it's time to compile all seven files and test the init command.

  • Compile all Java files by running this command in your terminal:
javac minigit\*.java

What does this command do?

javac is the Java compiler. The minigit\*.java wildcard tells it to compile every .java file inside the minigit folder. It produces .class files that Java can execute.

  • If the command succeeds, you should see no output at all. That means everything compiled cleanly.

Seeing compilation errors?

Make sure you are running the command from the minigit-project folder (the parent of minigit), not from inside the minigit folder itself. The package minigit; declaration requires the compiler to run from the parent directory.

Check that every file starts with package minigit; as its first line. A missing or misspelled package declaration causes the compiler to fail.

Why is my Java code failing to compile?

  • Initialize your first MiniGit repository by running this command:
java minigit.Main init
  • You should see this output:
Initialized empty MiniGit repository in .minigit/
  • Verify the directory structure was created by running:
ls .minigit
  • You should see HEAD, index, objects, and refs listed in the output.
  • Run the init command a second time to confirm idempotency:
java minigit.Main init
  • This time you should see: MiniGit repository already exists.

Don't see the init output?

If you see an error about the class not being found, make sure you are running from the minigit-project directory. The command java minigit.Main expects to find the minigit package folder in your current working directory.

If you see an error about Main not having a main method, double check that your method signature matches public static void main(String[] args) exactly.

My MiniGit init command is not working. Help me debug.

✔️ Awesome, I've got everything!

Great. Make sure all seven files are saved in your minigit folder.

ⓧ I'd like to double check the full code

Here is the complete code for Main.java:

package minigit;

public class Main {
    public static void main(String[] args) {
        if (args.length == 0) {
            System.out.println("Usage: java minigit.Main <command> [args]");
            System.out.println("Commands: init, add, status, commit, log");
            return;
        }

        String command = args[0];

        switch (command) {
            case "init":
                Repository.init();
                break;
            case "add":
                if (args.length < 2) {
                    System.out.println("Usage: java minigit.Main add <file>");
                    return;
                }
                Repository.add(args[1]);
                break;
            case "status":
                Repository.status();
                break;
            case "commit":
                if (args.length < 3 || !args[1].equals("-m")) {
                    System.out.println("Usage: java minigit.Main commit -m \"message\"");
                    return;
                }
                Repository.commit(args[2]);
                break;
            case "log":
                Repository.log();
                break;
            default:
                System.out.println("Unknown command: " + command);
        }
    }
}

Your object store is built and the init command works. Next up, you'll add the ability to stage files with add and check their status.

Stage Files with Add, Status, and Ignore

Your MiniGit repository can initialize itself, but it has no way to track files yet. Real version control starts when you can tell the system which files to watch and then ask it what has changed.

In this step, you will build a .minigitignore parser to filter out unwanted files, an add command that stages files into the index, and a status command that compares three states: the working directory, the staging area, and the last commit.

In this step, get ready to:
  • Build an ignore parser that supports exact names, wildcard extensions, and directory patterns.
  • Add staging and status commands to your repository manager.
  • Test the full staging workflow with test files and a .minigitignore file.
Build the ignore parser

Real Git uses a .gitignore file to exclude files like compiled binaries and build artifacts from version control. MiniGit needs the same capability.

  • Create a new file called IgnoreParser.java inside the minigit folder.
  • Paste the following code into the file:
package minigit;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class IgnoreParser {

    // Stores patterns loaded from .minigitignore
    private final List<String> patterns;

    public IgnoreParser(Path ignoreFile) {
        patterns = new ArrayList<>();
        if (Files.exists(ignoreFile)) {
            try {
                // Read each line, skip blanks and comments
                List<String> lines = Files.readAllLines(ignoreFile);
                for (String line : lines) {
                    String trimmed = line.trim();
                    if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
                        patterns.add(trimmed);
                    }
                }
            } catch (IOException e) {
                System.out.println("Warning: Could not read .minigitignore");
            }
        }
    }

    public boolean isIgnored(String relativePath) {
        // Normalize to forward slashes for cross-platform consistency
        String normalized = relativePath.replace("\\", "/");
        for (String pattern : patterns) {
            if (matchesPattern(normalized, pattern)) {
                return true;
            }
        }
        return false;
    }

    private boolean matchesPattern(String path, String pattern) {
        // Directory pattern: "build/" matches the directory and everything inside
        if (pattern.endsWith("/")) {
            String dirName = pattern.substring(0, pattern.length() - 1);
            if (path.startsWith(dirName + "/") || path.equals(dirName)) {
                return true;
            }
        }

        // Wildcard extension: "*.class" matches any file ending in .class
        if (pattern.startsWith("*.")) {
            String extension = pattern.substring(1);
            String fileName = path.contains("/")
                    ? path.substring(path.lastIndexOf("/") + 1)
                    : path;
            if (fileName.endsWith(extension)) {
                return true;
            }
        }

        // Exact filename: "secret.txt" matches any file with that name
        if (!pattern.contains("*") && !pattern.endsWith("/")) {
            String fileName = path.contains("/")
                    ? path.substring(path.lastIndexOf("/") + 1)
                    : path;
            if (fileName.equals(pattern) || path.equals(pattern)) {
                return true;
            }
        }

        return false;
    }
}

What does this code do?

The constructor reads a .minigitignore file line by line, keeping every non-empty line that does not start with #. If the file does not exist yet, the parser simply has zero patterns.

The matchesPattern() method handles three pattern types. Patterns ending with / match entire directories. Patterns starting with *. match file extensions. Everything else matches exact filenames.

  • Save IgnoreParser.java.
  • You will not see a change yet. The add and status commands (coming next) will use this parser.

✔️ Awesome, I've got everything!

Great. Make sure you have saved IgnoreParser.java before continuing.

ⓧ I'd like to double check the full code

package minigit;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public class IgnoreParser {

    private final List<String> patterns;

    public IgnoreParser(Path ignoreFile) {
        patterns = new ArrayList<>();
        if (Files.exists(ignoreFile)) {
            try {
                List<String> lines = Files.readAllLines(ignoreFile);
                for (String line : lines) {
                    String trimmed = line.trim();
                    if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
                        patterns.add(trimmed);
                    }
                }
            } catch (IOException e) {
                System.out.println("Warning: Could not read .minigitignore");
            }
        }
    }

    public boolean isIgnored(String relativePath) {
        String normalized = relativePath.replace("\\", "/");
        for (String pattern : patterns) {
            if (matchesPattern(normalized, pattern)) {
                return true;
            }
        }
        return false;
    }

    private boolean matchesPattern(String path, String pattern) {
        if (pattern.endsWith("/")) {
            String dirName = pattern.substring(0, pattern.length() - 1);
            if (path.startsWith(dirName + "/") || path.equals(dirName)) {
                return true;
            }
        }

        if (pattern.startsWith("*.")) {
            String extension = pattern.substring(1);
            String fileName = path.contains("/")
                    ? path.substring(path.lastIndexOf("/") + 1)
                    : path;
            if (fileName.endsWith(extension)) {
                return true;
            }
        }

        if (!pattern.contains("*") && !pattern.endsWith("/")) {
            String fileName = path.contains("/")
                    ? path.substring(path.lastIndexOf("/") + 1)
                    : path;
            if (fileName.equals(pattern) || path.equals(pattern)) {
                return true;
            }
        }

        return false;
    }
}
Add the add and status commands to Repository.java

The staging pipeline needs two pieces: a private helper that hashes and stores a single file, and a public add() method that handles both single files and directories. Then a status() method compares three states to tell the user what has changed.

  • In Repository.java, find the closing brace of the init() method.
  • Add the following add() method directly below it:
    public static void add(String filePath) {
        checkRepo();
        Path file = Path.of(filePath).normalize();

        if (!Files.exists(file)) {
            System.out.println("File not found: " + filePath);
            return;
        }

        // Load ignore patterns to filter out unwanted files
        IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

        try {
            if (Files.isDirectory(file)) {
                // Walk the directory tree, staging each non-ignored file
                try (Stream<Path> walk = Files.walk(file)) {
                    walk.filter(Files::isRegularFile).forEach(f -> {
                        String relPath = f.normalize().toString().replace("\\", "/");
                        if (!relPath.startsWith(".minigit/") && !ignoreParser.isIgnored(relPath)) {
                            addSingleFile(f, relPath);
                        }
                    });
                }
            } else {
                // Stage a single file, checking ignore rules first
                String relPath = file.toString().replace("\\", "/");
                if (ignoreParser.isIgnored(relPath)) {
                    System.out.println("Warning: " + relPath + " is in .minigitignore. Skipping.");
                    return;
                }
                addSingleFile(file, relPath);
            }
        } catch (IOException e) {
            System.out.println("Error adding file: " + e.getMessage());
        }
    }

What does the add command do?

When the target is a single file, add() checks the ignore rules and then delegates to addSingleFile() (which you will add in a moment).

When the target is a directory, Files.walk() traverses every file in the tree. Each file is filtered against the IgnoreParser and the .minigit/ directory is always excluded so MiniGit never tries to track its own internals.

  • Now add the status() method directly below the add() method you just added:
    public static void status() {
        checkRepo();
        try {
            Index index = loadIndex();
            IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

            // Collect all non-ignored working directory files
            Set<String> workingFiles = new TreeSet<>();
            try (Stream<Path> walk = Files.walk(Path.of("."))) {
                walk.filter(Files::isRegularFile)
                    .map(p -> p.normalize().toString().replace("\\", "/"))
                    .filter(p -> !p.startsWith(".minigit/"))
                    .filter(p -> !ignoreParser.isIgnored(p))
                    .forEach(workingFiles::add);
            }

            // Load the last committed tree (if any) for comparison
            Map<String, String> committedFiles = new TreeMap<>();
            String headHash = getHead();
            if (headHash != null) {
                Commit headCommit = (Commit) loadObject(headHash);
                Tree headTree = (Tree) loadObject(headCommit.getTreeHash());
                committedFiles.putAll(headTree.getEntries());
            }

            Map<String, String> stagedFiles = index.getEntries();

            // Compare staged files against last commit
            List<String> stagedNew = new ArrayList<>();
            List<String> stagedModified = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                if (!committedFiles.containsKey(entry.getKey())) {
                    stagedNew.add(entry.getKey());
                } else if (!committedFiles.get(entry.getKey()).equals(entry.getValue())) {
                    stagedModified.add(entry.getKey());
                }
            }

            // Compare working directory against staging area
            List<String> modifiedNotStaged = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                Path filePath = Path.of(entry.getKey());
                if (Files.exists(filePath)) {
                    byte[] content = Files.readAllBytes(filePath);
                    String currentHash = Utils.sha256(content);
                    if (!currentHash.equals(entry.getValue())) {
                        modifiedNotStaged.add(entry.getKey());
                    }
                }
            }

            // Find files not in the index or last commit
            List<String> untracked = new ArrayList<>();
            for (String file : workingFiles) {
                if (!stagedFiles.containsKey(file) && !committedFiles.containsKey(file)) {
                    untracked.add(file);
                }
            }

            boolean clean = true;

            if (!stagedNew.isEmpty() || !stagedModified.isEmpty()) {
                System.out.println("Staged changes:");
                for (String f : stagedNew) {
                    System.out.println("  new file: " + f);
                }
                for (String f : stagedModified) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!modifiedNotStaged.isEmpty()) {
                System.out.println("Modified but not staged:");
                for (String f : modifiedNotStaged) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!untracked.isEmpty()) {
                System.out.println("Untracked files:");
                for (String f : untracked) {
                    System.out.println("  " + f);
                }
                System.out.println();
                clean = false;
            }

            if (clean) {
                System.out.println("Nothing to report. Working tree clean.");
            }

        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error reading status: " + e.getMessage());
        }
    }

What does the status command do?

The status() method compares three layers. The working directory is what is on disk right now. The staging area (the index) is what has been added. The last commit is the most recent snapshot.

By diffing the index against the last commit, it finds staged changes. By diffing the working directory against the index, it finds modified-but-not-staged files. Anything in the working directory that is not in the index or the last commit is untracked.

  • Finally, add the private addSingleFile() helper method directly below status(), before the existing saveObject() method:
    private static void addSingleFile(Path file, String relativePath) {
        try {
            // Read the file and compute its SHA-256 hash
            byte[] content = Files.readAllBytes(file);
            String blobHash = Utils.sha256(content);

            // Store file content as a Blob in the object store
            Blob blob = new Blob(content);
            saveObject(blobHash, blob);

            // Update the staging area with this file's path and hash
            Index index = loadIndex();
            index.add(relativePath, blobHash);
            Utils.serialize(index, INDEX_FILE);

            System.out.println("Staged: " + relativePath);
        } catch (IOException e) {
            System.out.println("Error staging " + relativePath + ": " + e.getMessage());
        }
    }

Why does deduplication happen automatically?

The addSingleFile() method hashes the file content with SHA-256 and uses that hash as the storage path. If two files have identical content, they produce the same hash and the same path. The saveObject() helper skips writing if the file already exists. This is content-addressable storage in action.

The Main.java file already has the add and status cases wired up in the switch statement from the previous step. No changes are needed there.

  • Save Repository.java.
  • Compile all files by running this command in the terminal:
javac minigit\*.java

You should see no errors. If the compiler finishes silently, everything compiled successfully.

Seeing a compilation error?

  • If the error mentions cannot find symbol for addSingleFile, make sure the addSingleFile() method is inside the Repository class, not after its closing brace.
  • If the error mentions IgnoreParser, confirm that IgnoreParser.java is saved inside the minigit folder and its package declaration says package minigit;.
  • Check that all three new methods (add, status, addSingleFile) are inside the Repository class braces and have matching curly braces.

Why won't my MiniGit project compile after adding the add and status methods?

✔️ Awesome, I've got everything!

Great. Make sure Repository.java is saved before moving on to testing.

ⓧ I'd like to double check the full code

package minigit;

import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Stream;

public class Repository {

    private static final Path MINIGIT_DIR = Path.of(".minigit");
    private static final Path OBJECTS_DIR = MINIGIT_DIR.resolve("objects");
    private static final Path REFS_DIR = MINIGIT_DIR.resolve("refs");
    private static final Path HEAD_FILE = MINIGIT_DIR.resolve("HEAD");
    private static final Path INDEX_FILE = MINIGIT_DIR.resolve("index");
    private static final Path IGNORE_FILE = Path.of(".minigitignore");

    public static void init() {
        if (Files.exists(MINIGIT_DIR)) {
            System.out.println("MiniGit repository already exists.");
            return;
        }
        try {
            Files.createDirectories(OBJECTS_DIR);
            Files.createDirectories(REFS_DIR);
            Files.writeString(HEAD_FILE, "");
            Utils.serialize(new Index(), INDEX_FILE);
            System.out.println("Initialized empty MiniGit repository in .minigit/");
        } catch (IOException e) {
            System.out.println("Error initializing repository: " + e.getMessage());
        }
    }

    public static void add(String filePath) {
        checkRepo();
        Path file = Path.of(filePath).normalize();

        if (!Files.exists(file)) {
            System.out.println("File not found: " + filePath);
            return;
        }

        IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

        try {
            if (Files.isDirectory(file)) {
                try (Stream<Path> walk = Files.walk(file)) {
                    walk.filter(Files::isRegularFile).forEach(f -> {
                        String relPath = f.normalize().toString().replace("\\", "/");
                        if (!relPath.startsWith(".minigit/") && !ignoreParser.isIgnored(relPath)) {
                            addSingleFile(f, relPath);
                        }
                    });
                }
            } else {
                String relPath = file.toString().replace("\\", "/");
                if (ignoreParser.isIgnored(relPath)) {
                    System.out.println("Warning: " + relPath + " is in .minigitignore. Skipping.");
                    return;
                }
                addSingleFile(file, relPath);
            }
        } catch (IOException e) {
            System.out.println("Error adding file: " + e.getMessage());
        }
    }

    public static void status() {
        checkRepo();
        try {
            Index index = loadIndex();
            IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

            Set<String> workingFiles = new TreeSet<>();
            try (Stream<Path> walk = Files.walk(Path.of("."))) {
                walk.filter(Files::isRegularFile)
                    .map(p -> p.normalize().toString().replace("\\", "/"))
                    .filter(p -> !p.startsWith(".minigit/"))
                    .filter(p -> !ignoreParser.isIgnored(p))
                    .forEach(workingFiles::add);
            }

            Map<String, String> committedFiles = new TreeMap<>();
            String headHash = getHead();
            if (headHash != null) {
                Commit headCommit = (Commit) loadObject(headHash);
                Tree headTree = (Tree) loadObject(headCommit.getTreeHash());
                committedFiles.putAll(headTree.getEntries());
            }

            Map<String, String> stagedFiles = index.getEntries();

            List<String> stagedNew = new ArrayList<>();
            List<String> stagedModified = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                if (!committedFiles.containsKey(entry.getKey())) {
                    stagedNew.add(entry.getKey());
                } else if (!committedFiles.get(entry.getKey()).equals(entry.getValue())) {
                    stagedModified.add(entry.getKey());
                }
            }

            List<String> modifiedNotStaged = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                Path filePath = Path.of(entry.getKey());
                if (Files.exists(filePath)) {
                    byte[] content = Files.readAllBytes(filePath);
                    String currentHash = Utils.sha256(content);
                    if (!currentHash.equals(entry.getValue())) {
                        modifiedNotStaged.add(entry.getKey());
                    }
                }
            }

            List<String> untracked = new ArrayList<>();
            for (String file : workingFiles) {
                if (!stagedFiles.containsKey(file) && !committedFiles.containsKey(file)) {
                    untracked.add(file);
                }
            }

            boolean clean = true;

            if (!stagedNew.isEmpty() || !stagedModified.isEmpty()) {
                System.out.println("Staged changes:");
                for (String f : stagedNew) {
                    System.out.println("  new file: " + f);
                }
                for (String f : stagedModified) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!modifiedNotStaged.isEmpty()) {
                System.out.println("Modified but not staged:");
                for (String f : modifiedNotStaged) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!untracked.isEmpty()) {
                System.out.println("Untracked files:");
                for (String f : untracked) {
                    System.out.println("  " + f);
                }
                System.out.println();
                clean = false;
            }

            if (clean) {
                System.out.println("Nothing to report. Working tree clean.");
            }

        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error reading status: " + e.getMessage());
        }
    }

    private static void addSingleFile(Path file, String relativePath) {
        try {
            byte[] content = Files.readAllBytes(file);
            String blobHash = Utils.sha256(content);

            Blob blob = new Blob(content);
            saveObject(blobHash, blob);

            Index index = loadIndex();
            index.add(relativePath, blobHash);
            Utils.serialize(index, INDEX_FILE);

            System.out.println("Staged: " + relativePath);
        } catch (IOException e) {
            System.out.println("Error staging " + relativePath + ": " + e.getMessage());
        }
    }

    private static void saveObject(String hash, Serializable obj) throws IOException {
        Path dir = OBJECTS_DIR.resolve(hash.substring(0, 2));
        Path file = dir.resolve(hash.substring(2));
        if (!Files.exists(file)) {
            Utils.serialize(obj, file);
        }
    }

    private static Object loadObject(String hash) throws IOException, ClassNotFoundException {
        Path file = OBJECTS_DIR.resolve(hash.substring(0, 2)).resolve(hash.substring(2));
        return Utils.deserialize(file);
    }

    private static String getHead() throws IOException {
        if (!Files.exists(HEAD_FILE)) {
            return null;
        }
        String head = Files.readString(HEAD_FILE).trim();
        return head.isEmpty() ? null : head;
    }

    private static void setHead(String commitHash) throws IOException {
        Files.writeString(HEAD_FILE, commitHash);
    }

    private static Index loadIndex() {
        try {
            if (Files.exists(INDEX_FILE) && Files.size(INDEX_FILE) > 0) {
                return (Index) Utils.deserialize(INDEX_FILE);
            }
        } catch (IOException | ClassNotFoundException e) {
            // Return fresh index if loading fails
        }
        return new Index();
    }

    private static void checkRepo() {
        if (!Files.exists(MINIGIT_DIR)) {
            System.out.println("Not a MiniGit repository. Run 'minigit init' first.");
            System.exit(1);
        }
    }
}
Test staging and the ignore system

You need some test files to exercise the add and status commands. You will also create a .minigitignore file to prove that the ignore parser works.

  • Create test files and a .minigitignore by running these commands:
echo Hello World > hello.txt
mkdir src
echo public class App {} > src\App.java
echo *.class > .minigitignore

What did these commands create?

  • hello.txt is a plain text file MiniGit should track.
  • src/App.java is a Java source file in a subdirectory, testing that recursive staging works.
  • .minigitignore contains the pattern *.class, which tells MiniGit to ignore compiled Java files.
  • Confirm the files exist by running:
ls

You should see hello.txt, .minigitignore, the src folder, and the minigit folder in the output.

  • Check which files MiniGit sees by running:
java minigit.Main status

You should see an "Untracked files:" section listing .minigitignore, hello.txt, src/App.java, and the Java source files. Notice that .class files do not appear. The ignore parser filtered them out.

  • Stage a single file by running:
java minigit.Main add hello.txt

You should see Staged: hello.txt in the output.

  • Run status again to see the staged file listed separately from untracked files:
java minigit.Main status

You should now see a "Staged changes:" section with hello.txt listed as a new file, and the remaining files still under "Untracked files:".

  • Stage all remaining non-ignored files by running:
java minigit.Main add .

You should see each file staged individually. The .class files and .minigit/ internals are excluded automatically.

How can I verify deduplication?

Run java minigit.Main add hello.txt a second time. It prints "Staged" again, but browse the .minigit/objects/ directory. You will find only one blob for hello.txt because the hash has not changed. Identical content is stored exactly once.

Status showing no files or unexpected output?

  • Make sure you are running commands from inside the minigit-project folder (the one that contains .minigit/).
  • If you see "Not a MiniGit repository", you need to run java minigit.Main init first.
  • If .class files appear in the untracked list, check that your .minigitignore file contains exactly *.class and is saved in the minigit-project root folder.

My MiniGit status or add command is not working as expected. Help me debug.

Your staging pipeline is working. MiniGit can now track files, walk directories, filter out ignored patterns, and detect what has changed. Next up, you will build the commit command to freeze these staged files into immutable snapshots and the log command to walk through your project's history.

Create Commits and View History

Your staging area is working. You can add files, check their status, and filter them with .minigitignore. Now comes the most important part of any version control system: commits.

Every commit is an immutable snapshot of your staged files, linked to the previous commit by a parent hash. This chain of snapshots forms your project's history.

In this step, get ready to:
  • Add a commit command that creates immutable snapshots from staged files.
  • Add a log command that walks the commit chain to display history.
  • Test the full workflow with multiple commits and verify deduplication.
Add the commit method to Repository.java

Every commit follows a precise workflow. Load the staging area, build a Tree snapshot from it, check if anything actually changed since the last commit, then create and store the new Commit object.

  • In Repository.java, scroll down to the closing brace of the status() method.
  • Add the following commit() method below it:
    public static void commit(String message) {
        checkRepo();
        Index index = loadIndex();

        // Refuse to commit if nothing is staged
        if (index.isEmpty()) {
            System.out.println("Nothing to commit. Use 'minigit add' to stage files.");
            return;
        }

        try {
            // Build a tree snapshot from all staged entries
            Tree tree = new Tree(index.getEntries());
            String treeHash = tree.computeHash();
            saveObject(treeHash, tree);

            // Get the current HEAD as the parent for this new commit
            String parentHash = getHead();

            // Prevent empty commits by comparing tree hashes
            if (parentHash != null) {
                Commit parentCommit = (Commit) loadObject(parentHash);
                if (parentCommit.getTreeHash().equals(treeHash)) {
                    System.out.println("Nothing to commit. Working tree unchanged.");
                    return;
                }
            }

            // Create the commit object and store it
            Commit commit = new Commit(treeHash, parentHash, message);
            String commitHash = commit.computeHash();
            saveObject(commitHash, commit);

            // Point HEAD to the new commit
            setHead(commitHash);

            System.out.println("Committed: " + commitHash.substring(0, 7) + " " + message);
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error creating commit: " + e.getMessage());
        }
    }

What does this code do?

The commit workflow mirrors how real Git works internally.

  • new Tree(index.getEntries()) builds a directory snapshot from all staged files. The tree's hash is computed from its sorted entries, so identical file sets always produce the same hash.
  • The method checks if the new tree hash matches the parent commit's tree hash. If they match, nothing changed, so the commit is rejected.
  • new Commit(treeHash, parentHash, message) creates an immutable snapshot that records the tree reference, a pointer to the parent commit, the message, and a timestamp.
  • setHead(commitHash) updates the HEAD file to point at the new commit. This is how MiniGit knows which commit is "current."
  • Save Repository.java.
  • You won't see a change yet. The next method (log()) uses this to display the commit history.
Add the log method to Repository.java

The log command walks backward through the commit chain. Starting from HEAD, it loads each Commit object, prints its details, then follows the parentHash pointer to the previous commit until it reaches null.

  • In Repository.java, add the following log() method directly below the commit() method you just added:
    public static void log() {
        checkRepo();
        try {
            // Start from the latest commit
            String currentHash = getHead();

            if (currentHash == null) {
                System.out.println("No commits yet.");
                return;
            }

            // Walk backward through the commit chain
            while (currentHash != null) {
                Commit commit = (Commit) loadObject(currentHash);
                System.out.println("commit " + currentHash);
                System.out.println("Date:   " + commit.getTimestamp());
                System.out.println("        " + commit.getMessage());
                System.out.println();
                // Follow the parent pointer to the previous commit
                currentHash = commit.getParentHash();
            }
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error reading log: " + e.getMessage());
        }
    }

What does this code do?

  • getHead() reads the HEAD file to find the hash of the most recent commit. If HEAD is empty, there are no commits yet.
  • The while loop loads each commit from the object store, prints its hash, timestamp, and message, then moves to the parent. This is a linked-list traversal through immutable data.
  • The loop ends when getParentHash() returns null, which means you've reached the very first commit.

What about Main.java?

If you open Main.java, you'll see the commit and log cases already wired into the switch statement from when you created the CLI entry point. No changes needed there.

  • Save Repository.java.

Seeing a red underline on commit() or log()?

  • Make sure both methods are inside the Repository class body, between the closing brace of status() and the addSingleFile() method.
  • Check that every opening brace { has a matching closing brace }. A missing brace will cause errors across the entire file.

Why is my commit() or log() method showing errors?

✔️ Awesome, I've got everything!

Great. Double check you've saved Repository.java.

ⓧ I'd like to double check the full code

package minigit;

import java.io.IOException;
import java.io.Serializable;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Stream;

public class Repository {

    private static final Path MINIGIT_DIR = Path.of(".minigit");
    private static final Path OBJECTS_DIR = MINIGIT_DIR.resolve("objects");
    private static final Path REFS_DIR = MINIGIT_DIR.resolve("refs");
    private static final Path HEAD_FILE = MINIGIT_DIR.resolve("HEAD");
    private static final Path INDEX_FILE = MINIGIT_DIR.resolve("index");
    private static final Path IGNORE_FILE = Path.of(".minigitignore");

    public static void init() {
        if (Files.exists(MINIGIT_DIR)) {
            System.out.println("MiniGit repository already exists.");
            return;
        }
        try {
            Files.createDirectories(OBJECTS_DIR);
            Files.createDirectories(REFS_DIR);
            Files.writeString(HEAD_FILE, "");
            Utils.serialize(new Index(), INDEX_FILE);
            System.out.println("Initialized empty MiniGit repository in .minigit/");
        } catch (IOException e) {
            System.out.println("Error initializing repository: " + e.getMessage());
        }
    }

    public static void add(String filePath) {
        checkRepo();
        Path file = Path.of(filePath).normalize();

        if (!Files.exists(file)) {
            System.out.println("File not found: " + filePath);
            return;
        }

        IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

        try {
            if (Files.isDirectory(file)) {
                try (Stream<Path> walk = Files.walk(file)) {
                    walk.filter(Files::isRegularFile).forEach(f -> {
                        String relPath = f.normalize().toString().replace("\\", "/");
                        if (!relPath.startsWith(".minigit/") && !ignoreParser.isIgnored(relPath)) {
                            addSingleFile(f, relPath);
                        }
                    });
                }
            } else {
                String relPath = file.toString().replace("\\", "/");
                if (ignoreParser.isIgnored(relPath)) {
                    System.out.println("Warning: " + relPath + " is in .minigitignore. Skipping.");
                    return;
                }
                addSingleFile(file, relPath);
            }
        } catch (IOException e) {
            System.out.println("Error adding file: " + e.getMessage());
        }
    }

    public static void status() {
        checkRepo();
        try {
            Index index = loadIndex();
            IgnoreParser ignoreParser = new IgnoreParser(IGNORE_FILE);

            Set<String> workingFiles = new TreeSet<>();
            try (Stream<Path> walk = Files.walk(Path.of("."))) {
                walk.filter(Files::isRegularFile)
                    .map(p -> p.normalize().toString().replace("\\", "/"))
                    .filter(p -> !p.startsWith(".minigit/"))
                    .filter(p -> !ignoreParser.isIgnored(p))
                    .forEach(workingFiles::add);
            }

            Map<String, String> committedFiles = new TreeMap<>();
            String headHash = getHead();
            if (headHash != null) {
                Commit headCommit = (Commit) loadObject(headHash);
                Tree headTree = (Tree) loadObject(headCommit.getTreeHash());
                committedFiles.putAll(headTree.getEntries());
            }

            Map<String, String> stagedFiles = index.getEntries();

            List<String> stagedNew = new ArrayList<>();
            List<String> stagedModified = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                if (!committedFiles.containsKey(entry.getKey())) {
                    stagedNew.add(entry.getKey());
                } else if (!committedFiles.get(entry.getKey()).equals(entry.getValue())) {
                    stagedModified.add(entry.getKey());
                }
            }

            List<String> modifiedNotStaged = new ArrayList<>();
            for (Map.Entry<String, String> entry : stagedFiles.entrySet()) {
                Path filePath = Path.of(entry.getKey());
                if (Files.exists(filePath)) {
                    byte[] content = Files.readAllBytes(filePath);
                    String currentHash = Utils.sha256(content);
                    if (!currentHash.equals(entry.getValue())) {
                        modifiedNotStaged.add(entry.getKey());
                    }
                }
            }

            List<String> untracked = new ArrayList<>();
            for (String file : workingFiles) {
                if (!stagedFiles.containsKey(file) && !committedFiles.containsKey(file)) {
                    untracked.add(file);
                }
            }

            boolean clean = true;

            if (!stagedNew.isEmpty() || !stagedModified.isEmpty()) {
                System.out.println("Staged changes:");
                for (String f : stagedNew) {
                    System.out.println("  new file: " + f);
                }
                for (String f : stagedModified) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!modifiedNotStaged.isEmpty()) {
                System.out.println("Modified but not staged:");
                for (String f : modifiedNotStaged) {
                    System.out.println("  modified: " + f);
                }
                System.out.println();
                clean = false;
            }

            if (!untracked.isEmpty()) {
                System.out.println("Untracked files:");
                for (String f : untracked) {
                    System.out.println("  " + f);
                }
                System.out.println();
                clean = false;
            }

            if (clean) {
                System.out.println("Nothing to report. Working tree clean.");
            }

        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error reading status: " + e.getMessage());
        }
    }

    public static void commit(String message) {
        checkRepo();
        Index index = loadIndex();

        if (index.isEmpty()) {
            System.out.println("Nothing to commit. Use 'minigit add' to stage files.");
            return;
        }

        try {
            Tree tree = new Tree(index.getEntries());
            String treeHash = tree.computeHash();
            saveObject(treeHash, tree);

            String parentHash = getHead();

            if (parentHash != null) {
                Commit parentCommit = (Commit) loadObject(parentHash);
                if (parentCommit.getTreeHash().equals(treeHash)) {
                    System.out.println("Nothing to commit. Working tree unchanged.");
                    return;
                }
            }

            Commit commit = new Commit(treeHash, parentHash, message);
            String commitHash = commit.computeHash();
            saveObject(commitHash, commit);

            setHead(commitHash);

            System.out.println("Committed: " + commitHash.substring(0, 7) + " " + message);
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error creating commit: " + e.getMessage());
        }
    }

    public static void log() {
        checkRepo();
        try {
            String currentHash = getHead();

            if (currentHash == null) {
                System.out.println("No commits yet.");
                return;
            }

            while (currentHash != null) {
                Commit commit = (Commit) loadObject(currentHash);
                System.out.println("commit " + currentHash);
                System.out.println("Date:   " + commit.getTimestamp());
                System.out.println("        " + commit.getMessage());
                System.out.println();
                currentHash = commit.getParentHash();
            }
        } catch (IOException | ClassNotFoundException e) {
            System.out.println("Error reading log: " + e.getMessage());
        }
    }

    private static void addSingleFile(Path file, String relativePath) {
        try {
            byte[] content = Files.readAllBytes(file);
            String blobHash = Utils.sha256(content);

            Blob blob = new Blob(content);
            saveObject(blobHash, blob);

            Index index = loadIndex();
            index.add(relativePath, blobHash);
            Utils.serialize(index, INDEX_FILE);

            System.out.println("Staged: " + relativePath);
        } catch (IOException e) {
            System.out.println("Error staging " + relativePath + ": " + e.getMessage());
        }
    }

    private static void saveObject(String hash, Serializable obj) throws IOException {
        Path dir = OBJECTS_DIR.resolve(hash.substring(0, 2));
        Path file = dir.resolve(hash.substring(2));
        if (!Files.exists(file)) {
            Utils.serialize(obj, file);
        }
    }

    private static Object loadObject(String hash) throws IOException, ClassNotFoundException {
        Path file = OBJECTS_DIR.resolve(hash.substring(0, 2)).resolve(hash.substring(2));
        return Utils.deserialize(file);
    }

    private static String getHead() throws IOException {
        if (!Files.exists(HEAD_FILE)) {
            return null;
        }
        String head = Files.readString(HEAD_FILE).trim();
        return head.isEmpty() ? null : head;
    }

    private static void setHead(String commitHash) throws IOException {
        Files.writeString(HEAD_FILE, commitHash);
    }

    private static Index loadIndex() {
        try {
            if (Files.exists(INDEX_FILE) && Files.size(INDEX_FILE) > 0) {
                return (Index) Utils.deserialize(INDEX_FILE);
            }
        } catch (IOException | ClassNotFoundException e) {
            // Return fresh index if loading fails
        }
        return new Index();
    }

    private static void checkRepo() {
        if (!Files.exists(MINIGIT_DIR)) {
            System.out.println("Not a MiniGit repository. Run 'minigit init' first.");
            System.exit(1);
        }
    }
}
Test commits and view history

Time to see the full version control workflow in action. You'll make a commit, view the log, then modify a file and commit again to build a chain of history.

  • Compile all Java files by running this command in your terminal:
javac minigit\*.java

Seeing a compilation error?

  • Check for mismatched braces in Repository.java. The new commit() and log() methods must sit between the existing status() method and the private addSingleFile() method.
  • Make sure you saved Repository.java before compiling. Unsaved changes are not picked up by javac.

Help me fix my MiniGit compilation error.

  • Make your first commit by running:
java minigit.Main commit -m "Initial commit"
  • You should see output like Committed: a3f9bc2 Initial commit. Your hash will be different, but the format is the first 7 characters of the SHA-256 hash followed by your message.
  • View your commit history by running:
java minigit.Main log
  • You should see the full 64-character commit hash, a timestamp, and your commit message.

Why is the log hash longer than the commit output?

The commit command prints only the first 7 characters of the hash as a short reference. The log command shows the full 64-character SHA-256 hash. Real Git works the same way.

Now test the commit chain by making a second commit with a modified file.

  • Modify hello.txt, re-add it, and create a second commit by running these commands:
echo Hello MiniGit > hello.txt
java minigit.Main add hello.txt
java minigit.Main commit -m "Update greeting"
  • You should see Staged: hello.txt followed by a new Committed: line with a different short hash.
  • View the full history by running:
java minigit.Main log
  • You should see two commits. The most recent ("Update greeting") appears first, followed by "Initial commit". Each commit has its own unique hash and timestamp.

How does the parent chain work?

When you created the second commit, MiniGit read HEAD to find the first commit's hash and stored it as the parentHash. The log command follows these parent pointers backward, creating the chronological chain you see.

The first commit's parentHash is null, which is how the while loop knows to stop.

  • Verify that MiniGit prevents empty commits by running:
java minigit.Main commit -m "This should fail"
  • You should see Nothing to commit. Working tree unchanged. because the staged files haven't changed since the last commit.

What about deduplication?

Browse the .minigit\objects\ folder in the VS Code file explorer. Files that did not change between commits (like src/App.java and .minigitignore) share the same blob. Identical content produces the same SHA-256 hash, so it's only stored once.

Commit says 'Nothing to commit' on your first try?

  • Make sure you staged files with java minigit.Main add . or java minigit.Main add hello.txt in the previous step. Run java minigit.Main status to check what's staged.
  • If status shows no staged files, re-add your files with java minigit.Main add . and try committing again.

Help me figure out why my MiniGit commit isn't working.

Secret mission

Add a Checkout Command

Your version control system can create commits and view history. But what about going back in time? Add a checkout command that restores files from any previous commit, complete with short hash prefix resolution.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files, or delete them entirely.

Resources you used:

  • The minigit-project folder on your Desktop containing all Java source files, test files, and compiled .class files.
  • The .minigit/ directory inside your project folder containing serialized Java objects (blobs, trees, commits), the HEAD file, and the index file.

Keep everything running

No action needed. Choose this if you want to keep experimenting with MiniGit or extend it with new features like branching, merging, or diff support.

  • Your minigit-project folder uses minimal disk space. The serialized objects in .minigit/objects/ are small files that take up negligible storage.
  • If you want to reset the repository without deleting your source code, delete just the .minigit/ directory and run java minigit.Main init again to start fresh.

Pause - I'll come back to this later

There are no running processes or services to pause. Your project is just files on disk, so it stays exactly as you left it.

  • Close VS Code when you are done. Your files will be right where you left them when you come back.
  • To resume later, open VS Code, open the minigit-project folder from your Desktop, and pick up where you left off.

Delete - I don't want to use this again

Remove all project files from your machine. This deletes your source code, compiled classes, and the entire MiniGit object store.

  • Delete the entire project folder by running one of these commands in your terminal.

macOS

  • Delete the project folder by running this command:
rm -rf ~/Desktop/minigit-project
  • Confirm the folder is gone by running:
ls ~/Desktop | grep minigit

You should see no output, confirming the folder has been removed.

Windows

  • Delete the project folder by running this command:
rmdir /s /q %USERPROFILE%\Desktop\minigit-project
  • Confirm the folder is gone by running:
dir %USERPROFILE%\Desktop\minigit-project

You should see a "File Not Found" or "cannot find the path" message, confirming the folder has been removed.

Nice Work!

Nice Work!

You just built a fully functional version control system from scratch in pure Java with zero external dependencies. MiniGit proves you understand how Git actually works under the hood.

You've learned how to:

  • Implement content-addressable storage using SHA-256 hashing to deduplicate files automatically, mirroring the same architecture real Git uses.
  • Build four core object types (Blob, Tree, Commit, Index) that mirror Git's internal data model, with a staging area workflow powered by add, status, and commit commands.
  • Create immutable commits with parent links forming a history chain, a log command that walks that chain, and a .minigitignore system supporting exact names, wildcard extensions, and directory patterns.
  • Secret Mission: Added a checkout command to restore files from any previous commit using hash prefix resolution.

Ready to quiz yourself?