Контроль версий долгое время был для меня «чёрным ящиком»: я не понимал, как именно хранятся файлы, как формируются diff’ы и из чего состоят коммиты. А поскольку я люблю изобретать велосипеды, почему бы не попробовать реализовать git самому?
Хеширование
В основе git лежат хеши — а именно SHA-1. Когда вы коммитите файл, git вычисляет его хеш и сохраняет объект в .git/objects/. Затем, чтобы можно было снова найти этот файл, git создаёт объект tree — список файлов и поддиректорий с соответствующими хешами — хеширует его и тоже сохраняет в .git/objects/.
После этого создаётся объект commit, который содержит:
хеш дерева;
хеш предыдущего коммита;
автора;
коммитера;
сообщение коммита.
Таким образом, каждый коммит ссылается на предыдущий, образуя цепочку. Сам commit-объект тоже хешируется и сохраняется в .git/objects/.
Первой задачей было выбрать алгоритм хеширования. Git использует SHA-1 — старый и криптографически скомпрометированный алгоритм. В моём случае это не проблема: хеши нужны лишь для идентификации содержимого, а не для защиты секретов. Совместимость с git меня не интересовала, поэтому я решил использовать «новый» (с 2001 года) стандарт — SHA-256.
Сжатие объектов
Все объекты в git также сжимаются для экономии места, поэтому чтение и запись в .git/objects/ всегда сопровождаются сжатием и распаковкой. Git использует zlib, но если посмотреть на альтернативы, zstd выглядит более пе��спективно.

Поскольку совместимость с git мне не нужна, я выбрал zstd.
Название я тоже решил придумать своё — tvc, сокращение от Tony’s Version Control. Простое имя, внушающее доверие 🙂
Соответственно:
.tvc— аналог.git.tvcignore— аналог.gitignore
Реализация
С учётом принятых решений проект выглядел довольно прямолинейно. Нужно было реализовать следующие шаги:
чтение аргументов командной строки;
чтение ignore-файла;
ls— вывод неигнорируемых файлов в рабочей директории;хеширование файлов;
сжатие файлов;
распаковка файлов;
генерация tree-объектов;
генерация commit-объектов;
генерация файла
HEADcheckout коммитов.
Для реализации подобных сторонних проектов я почти всегда выбираю язык Rust, пусть в нём и не очень силён.
Команда ls
Команда ls оказалась довольно простой: я рекурсивно обхожу текущую директорию, пропускаю файлы и каталоги, попадающие под правила из .tvcignore, и вызываю callback для каждого файла.
match subcommand.as_str() {
"ls" => {
let cb = |path: &Path| {
let hash = sha256::try_digest(&path)
.unwrap_or("<invalid hash>".to_string());
println!("{}\t{}", path.display(), &hash);
};
read_dir_recursive(Path::new("./"), &ignore_rules, &cb).unwrap();
}
}В callback’е я хеширую содержимое файла с помощью sha256::try_digest(&path) (не знаю, почему хеширование иногда называют digesting, но по сути это одно и то же) и вывожу путь и хеш в stdout.
Сжатие и распаковка
Сжатие и распаковка с помощью библиотеки zstd тоже оказались тривиальными:
fn decompress_object(object: &str) -> std::io::Result<String> {
let path = PathBuf::from(format!("./.tvc/objects/{}", object));
let object = File::open(path)?;
let mut buf: String = String::new();
let mut decoder = zstd::Decoder::new(object)?;
decoder.read_to_string(&mut buf)?;
decoder.finish();
Ok(buf)
}fn compress_file(source: &Path, dest: &Path) -> std::io::Result<()> {
let input = File::open(source)?;
let output = File::create(dest)?;
let mut encoder = zstd::Encoder::new(output, 3)?;
std::io::copy(&mut &input, &mut encoder)?;
encoder.finish()?;
Ok(())
}Коммиты
Чтобы создать коммит, нужно сохранить следующие свойства:
тип объекта (commit);
состояние файловой системы на момент коммита (tree);
предыдущий коммит (HEAD, если он существует);
автора;
сообщение коммита.
Git также хранит размер объекта в заголовке и различает author и committer. Поскольку в большинстве случаев это один и тот же человек, я решил не реализовывать это различие. Оно полезно для merge и rebase, но я всё равно не планировал реализовывать эти возможности.
"commit" => {
let message = args.iter().skip(1)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ");
let author = "god"; // TODO: track commit author
let parent_hash = head;
let tree = generate_tree(Path::new("./"), &ignore_rules).expect("tree error");
let tree_hash = digest(tree);
/* ===== commit format =====
<type> \0
tree\t<tree>\n
parent\t<parent>\n
author\t<author>\n
message\t<message>\n
*/
let commit_object = format!(
"commit \0tree\t{}\nparent\t{}\nauthor\t{}\nmessage\t{}",
tree_hash, parent_hash, author, message
);
let commit_hash = digest(&commit_object);
let dest = PathBuf::from(format!("./.tvc/objects/{}", commit_hash));
let dest_file = File::create(dest)?;
let mut encoder = zstd::Encoder::new(dest_file, 3)?;
encoder.write_all(commit_object.as_bytes())?;
encoder.finish()?;
// Update HEAD
fs::write("./.tvc/HEAD", &commit_hash)?;
println!("HEAD is now at: {}", commit_hash);
}Вся «тяжёлая» работа здесь происходит в generate_tree(). Функция проходит по корневой директории, хеширует, сжимает и сохраняет каждый файл в .tvc/objects/, добавляя имя файла и его хеш в строку.
Поддиректории обрабатываются рекурсивно той же функцией. Получившийся tree-объект хешируется, сохраняется и включается в родительское дерево.
Если файл не изменился с предыдущего коммита, его хеш будет тем же, а значит новый объект не создаётся. Если в репозитории сотни файлов, но каждый коммит меняет лишь один-два, большинство объектов будут переиспользоваться.
Checkout коммитов
Для checkout пришлось парсить довольно неаккуратные форматы объектов, которые я сам же и придумал. Код получился шумным, но концептуально простым: по сути это разбиение строк для получения значений сообщения коммита, имени файла и хеша, предыдущего коммита и т. д.
После парсинга данные укладываются в структуры:
#[derive(Debug, Clone)]
struct Commit {
tree: String,
parent: String,
author: String,
message: String,
}
// Формирование структуры Commit из строки commit-объекта
impl From<String> for Commit {
fn from(string: String) -> Self {
...
}
}
#[derive(Debug, Clone)]
struct Tree {
trees: Vec<(String, Tree)>, // (path, Tree)
blobs: Vec<(String, String)>, // (path, hash)
}
impl Tree {
fn from_hash(string: &str) -> Self {
let object = decompress_object(string).unwrap();
Tree::from_string(object.as_str())
}
fn from_string(string: &str) -> Self {
...
}
}После этого можно реализовать метод, который восстанавливает файловую структуру. Чтобы случайно не затереть свой код (я ��естировал всё на самом tvc), я сделал обязательный аргумент пути, куда выполнять checkout:
impl Tree {
...
fn generate_fs(&self, path: &Path) -> std::io::Result<()> {
fs::create_dir(&path).unwrap_or(());
for (dirname, blob) in &self.blobs {
let file = decompress_object(blob)?;
fs::write(path.join(dirname), file.as_bytes())?;
}
for (dirname, tree) in &self.trees {
tree.generate_fs(&path.join(dirname))?;
}
Ok(())
}
}Итоги
Это было очень увлекательное упражнение. Оно хорошо показывает, что git по сути — это хранилище файлов, адресуемых по их содержимому: простая база «хеш → данные».
Самой сложной частью проекта оказалось не хеширование и не сжатие, а парсинг. Если бы я делал это снова, я бы, скорее всего, использовал хорошо определённый формат вроде JSON или YAML для хранения информации об объектах.
Если вам интересно посмотреть код — он доступен на GitHub.
