ANSHUL.LOG
Entry
002
Channel
Build
Date
Read
11 min / 734 words
Hash
a027f22

Building Ark: Git's object model from scratch

Blobs, trees and commits in Ark, a Git-style version control system in C++: the object header, the SHA-256 address, zlib on disk, and what a branch switch does.

Ark is a version control system written in C++ to see how Git works under the hood. It covers the everyday commands (init, add, commit, status, branch, switch, log) and the plumbing beneath them: hash-object, cat-file, write-tree, commit-tree and update-ref. This entry follows one file through the object model, using a real session against the current build.

A minimal Git-like version control system written in C++ to understand how Git works under the hood.
Ark, README.md

One file, one session

Everything starts with ark init, which lays out the repository and points main at a hash of 64 zeros, since there is no commit yet.

src/commands/init.cppcpp
std::filesystem::create_directory(ark_path + "/objects");
std::filesystem::create_directory(ark_path + "/refs");
std::filesystem::create_directory(ark_path + "/refs/heads");
std::ofstream(ark_path + "/refs/heads/main") << NULL_HASH;
std::ofstream(ark_path + "/HEAD") << "ref: refs/heads/main";
std::ofstream(ark_path + "/index");
std::ofstream(ark_path + "/config");

Then one file goes in. The binary lives at build/ark and the logger also prints timestamped [INFO] lines; both are trimmed from the sessions below.For example, ark add follows up with [INFO] Index loaded: 0 entries and [INFO] Index saved: 1 entries.

~/demo
$ ark initark initialised$ echo "Hello World" > file.txt$ ark hash-object file.txt7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284$ ark add file.txt$ cat .ark/index100644 7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284 file.txt

The index is plain text: a mode, a hash and a path per line. The hash is where the interesting part is.

Blobs: a header, then the bytes

A blob is a file’s contents with a small header in front: the word blob, a space, the length in bytes, and a NUL byte.

src/utils/objects.cppcpp
Blob::Blob(const std::string &filename) {
  std::ifstream file(filename, std::ios::binary);
  if (!file) {
    std::cout << "Error: cannot open file " << filename << std::endl;
    return;
  }
  std::ostringstream buffer;
  buffer << file.rdbuf();
  std::string file_content = buffer.str();

  // Create Git-like blob content (blob header + content)
  this->content = "blob " + std::to_string(file_content.size()) +
                  std::string("\0", 1) + file_content;

  this->hash = this->getSha256();
}

std::string("\0", 1) is doing real work there. A bare "\0" literal converts to an empty std::string, because the conversion stops at the first NUL; passing the length keeps the byte. For the twelve bytes of Hello World plus its newline, the object is twenty bytes:

blob 7c5c861, uncompressedtext
00000000: 626c 6f62 2031 3200 4865 6c6c 6f20 576f  blob 12.Hello Wo
00000010: 726c 640a                                rld.

The address is the hash

getSha256 runs OpenSSL’s SHA-256 over the whole content string, header included, and hex-encodes the 32-byte digest into the 64 characters that hash-object printed.

src/utils/objects.cppcpp
std::string Object::getSha256() {
  unsigned char hash[SHA256_DIGEST_LENGTH];
  SHA256_CTX sha256;
  SHA256_Init(&sha256);
  SHA256_Update(&sha256, content.c_str(), content.size());
  SHA256_Final(hash, &sha256);

  // 4. Convert hash to hex string
  std::ostringstream hexStream;
  for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) {
    hexStream << std::hex << std::setw(2) << std::setfill('0') << (int)hash[i];
  }
  return hexStream.str();
}

OpenSSL 3 marks the SHA256_Init / Update / Final trio as deprecated, and the build prints warnings for them.1 OpenSSL 3 steers new code toward the EVP interface (EVP_Digest and friends), which is what those warnings point to. They still work.

zlib, then a fan-out directory

Objects are compressed with zlib at its highest level before they touch the disk.

src/utils/compress.cppcpp
std::string compressObject(const std::string& input) {
    if (input.empty()) return std::string();
    uLongf destinationCapacity = compressBound(static_cast<uLong>(input.size()));
    std::string compressed;
    compressed.resize(destinationCapacity);
    int result = compress2(
        reinterpret_cast<Bytef*>(&compressed[0]),
        &destinationCapacity,
        reinterpret_cast<const Bytef*>(input.data()),
        static_cast<uLong>(input.size()),
        Z_BEST_COMPRESSION
    );

The first two hex characters of the hash name a directory and the other 62 name the file, the same fan-out Git uses. The compressed blob is 28 bytes and opens with 78 da, the zlib header for maximum compression.

~/demo
$ find .ark -type f | sort.ark/config.ark/HEAD.ark/index.ark/objects/7c/5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284.ark/refs/heads/main$ xxd .ark/objects/7c/* | head -100000000: 78da 4bca c94f 5230 3462 f048 cdc9 c957  x.K..OR04b.H...W

Trees: one line per entry

A tree is a directory snapshot. writeTreeToDisk writes one line per child, recursing into subdirectories first so their hashes exist before the parent lists them.

src/utils/objects.cppcpp
void Tree::writeTreeToDisk(TreeNode *root) {
  std::ostringstream buffer;

  for (const auto &[name, obj] : root->children) {
    std::string type;
    if (Blob *blob = dynamic_cast<Blob *>(obj)) {
      // Entry: <mode> <type> <hash> <name>\n
      buffer << blob->mode << " " << "blob" << " " << blob->hash << " " << name
             << "\n";
    } else if (TreeNode *node = dynamic_cast<TreeNode *>(obj)) {
      // Recursively write subtree first to get its hash
      writeTreeToDisk(node);
      buffer << "040000" << " " << "tree" << " " << node->hash << " " << name
             << "\n";
    }
  }

Each line carries the object type as well as the mode, hash and name. Decompressed, the tree from the first commit is a single line under its header:

tree becca28, uncompressedtext
tree 86\0100644 blob 7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284 file.txt

Commits: a tree, a parent, a name

A commit points at the root tree and at its parent, then records who made it and when. The message comes from the editor; USAGE.md documents setting GIT_EDITOR to script it.

~/demo
$ GIT_EDITOR="echo 'Initial commit' >" ark commitpassed checks$ cat .ark/refs/heads/mainb36cd6c1e33276a954de8dad1dcb0868eac3a0c0c90e83122bbff84669a9b48c$ ark cat-file b36cd6c1e33276a954de8dad1dcb0868eac3a0c0c90e83122bbff84669a9b48ctree becca28ce4645718dd333f13d8315e7bd20eb0a963cc15f3b779efad7a07e483parent 0000000000000000000000000000000000000000000000000000000000000000committer Anshul Kumar <itsanshulkumar03@gmail.com> 1789440978 +0530Initial commit

The first commit’s parent is the all-zero hash that init wrote into refs/heads/main. ARCHITECTURE.md sketches separate author and committer lines; the code writes a committer line only.

Fig. HEAD to blob after the first commit. Every hash comes from the session above.

Branches, HEAD and switching

A branch is a file under .ark/refs/heads/ holding a commit hash, and HEAD holds ref: refs/heads/main while you are on a branch. ark branch feature refuses to run until there is a commit, then copies HEAD’s commit into a new ref.

ark switch does the heavier lifting: it diffs the current commit’s tree against the target’s, points HEAD at the new branch, applies the diff to the working directory and rebuilds the index from the target tree.

src/commands/switch.cppcpp
Tree::diff(sourceCommit.tree->root, targetCommit.tree->root, diff,
           repo.root());

ref.setHeadToBranch(branchName);
Tree::buildFromDiff(diff);

Index idx;
idx.clear();

auto flat = targetCommit.tree->flatten();

That “already on this branch” check has history. It used to compare commit hashes, which broke in the most common case: right after ark branch feature, both branches point at the same commit.

switch.cpp, before 783aa18

std::string source_commit_hash = getHead();
std::string target_commit_hash = getBranchHash(branch_name);
if(source_commit_hash == target_commit_hash){
std::cerr<<"already on branch "<<branch_name<<std::endl;
return;
}

switch.cpp, after

std::string source_commit_hash = getHead();
std::string target_commit_hash = getBranchHash(branch_name);
std::string source_branch_name = getHeadBranchName();
if(!isHeadDetached() && source_branch_name == branch_name){
std::cerr<<"already on branch "<<branch_name<<std::endl;
return;
}
From commit 783aa18: bug-fix: switchBranch was not switching the branch when both branched pointed to same commit
~/demo
$ ark branch feature$ ark switch featureswitched branch to feature$ ark switch mainswitched branch to main$ ark branchfeature* main

What is there today

  • Thirteen command files under src/commands/, twelve of them wired into the dispatcher in main.cpp.
  • merge.cpp exists but is not in the command table yet, and USAGE.md lists merging as not implemented.
  • .arkignore matches exact paths, not globs.
  • Unit tests and an end-to-end test under tests/, built as the test_ark target.

Footnotes

  1. OpenSSL 3 steers new code toward the EVP interface (EVP_Digest and friends), which is what those warnings point to.