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.
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.
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.
$ ark initark initialised$ echo "Hello World" > file.txt$ ark hash-object file.txt7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284$ ark add file.txt$ cat .ark/index100644 7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284 file.txtThe 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.
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:
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.
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.
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.
$ 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...WTrees: 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.
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 86\0100644 blob 7c5c8610459154bdde4984be72c48fb5d9c1c4ac793a6b5976fe38fd1b0b1284 file.txtCommits: 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.
$ 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 commitThe 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.
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.
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;
}$ ark branch feature$ ark switch featureswitched branch to feature$ ark switch mainswitched branch to main$ ark branchfeature* mainWhat is there today
- Thirteen command files under
src/commands/, twelve of them wired into the dispatcher inmain.cpp. merge.cppexists but is not in the command table yet, andUSAGE.mdlists merging as not implemented..arkignorematches exact paths, not globs.- Unit tests and an end-to-end test under
tests/, built as thetest_arktarget.
Footnotes
-
OpenSSL 3 steers new code toward the EVP interface (
EVP_Digestand friends), which is what those warnings point to. ↩