Как реализовать простой контроль версий с помощью JavaScript, чтобы лучше разобраться в Git

Что такое Git и управление версиями в целом?

Это система, которая помогает отслеживать проект во времени. Так, с помощью Git мы можем легко погрузиться в историю исходного кода и посмотреть, как он выглядел в определенное время в прошлом.

Рабочий поток Git

Зачем разбираться в Git?

Во-первых, чтобы не использовать команды бездумно, когда что-то пойдет не так. Во-вторых, важно понимать, как работает система, с которой мы взаимодействуем каждый день.

Мы разберем основные понятия Git на примере реализации упрощенной системы управления версиями под названием Gitj.

Реализация

Выполнение первой команды Init

Как вы, наверное, знаете, мы запускаем проект командой git init, после чего Git создает папку .git и сохраняет в ней данные. Нам предстоит это реализовать.

Две важные папки, которые создает git,  —  это refs и objects. Objects (объекты)  —  это строительные блоки Git. Они могут быть 3 типов (на самом деле их 4): commit, tree и blob. Сейчас мы подробно рассмотрим каждый из этих типов. В папке refs есть подпапка heads, содержащая ветви и последний коммит каждой ветви (из названия должно быть понятно, что в ней хранится голова ветви). У нас также есть важный файл HEAD, который хранит текущую ветвь или коммит (иногда необходимо выполнить проверку в коммите, а не в ветви).

Создадим эти папки при вызове функции init.

const fs = require("fs");

function init() {
// создается папка .gitj и подпапки .gitj/objects,.gitj/refs и .gitj/refs/heads
fs.mkdirSync(".gitj");
fs.mkdirSync(".gitj/objects");
fs.mkdirSync(".gitj/refs");
fs.mkdirSync(".gitj/refs/heads");
// создается файл с именем .gitj/refs/heads/master
fs.writeFileSync(".gitj/refs/heads/master", "");
// создается файл с именем .gitj/HEAD
fs.writeFileSync(".gitj/HEAD", "ref: refs/heads/master");
}

init();

Git Add. Добавление файлов в индекс

В системе Git различают три различных секции проекта.

Секции проекта Git

Рабочая директория (Working Directory): обычная директория, в котором вы работаете, изменяете файлы, папки и структуры.

Область индексирования (Staging Directory): снимок момента времени в рабочей директории. Когда вы используете команду git add, она копирует файлы в папку .git. Следует помнить, что файлы области индексирования  —  это некий окончательный вариант, который нужно закоммитить в репозиторий.

Репозиторий (Repository): выполняя команду commit, вы создаете новый снимок в репозитории. Теперь можно указывать на этот снимок, используя SHA-хэш данного коммита (на файлы области индексирования указывать нельзя  —  это своего рода черновики).

Теперь реализуем команду git add.

Вот шаги, которые мы должны выполнить в команде git add.

  • Прочитать содержимое файла.
  • Хэшировать содержимое файла.
  • Использовать хэш в качестве имени файла и сохранить его в папке objects.
  • Если файл уже есть, то ничего не нужно делать. Если у вас есть 10 файлов с одинаковым содержимым (даже при условии разных имен файлов и расположения папок), Git не будет копировать их 10 раз, он может повторно использовать файл blob.

Кроме того, Git использует первые две буквы хэша для создания имени папки. Например, если значение хэша  —  4f9be057f0ea5d2ba72fd2c810e8d7b9aa98b469, Git сохраняет его в папке 4f и создает файл с оставшимися символами: 9be057f0ea5d2ba72fd2c810e8d7b9aa98b469. Причина в том, что когда со временем в одной папке оказывается много файлов, скорость доступа к ним может замедлиться. Используя первые две буквы в качестве имени папки, Git пытается предотвратить эту проблему.

const fs = require("fs");
const crypto = require("crypto");
function add(filename) {
try {
// файл существует?
fs.accessSync(filename);
// чтение файла
const content = fs.readFileSync(filename);
// хэширование файла
const hash = crypto.createHash("sha1");
hash.update(content);
const sha = hash.digest("hex");
// создание папки с использованием первых двух символов хэша, если ее еще нет
if (!fs.existsSync(`.gitj/objects/${sha.slice(0, 2)}`)) {
fs.mkdirSync(".gitj/objects/" + sha.slice(0, 2), { recursive: true });
}
if (fs.existsSync(`.gitj/objects/${sha.slice(0, 2)}/${sha.slice(2)}`)) {
// файл blob с таким же содержимым уже существует
process.exit(0);
}
// запись файла в папку objects
fs.writeFileSync(`.gitj/objects/${sha.slice(0, 2)}/${sha.slice(2)}`, content);
} catch (error) {
console.log(error);
console.log(`File ${filename} does not exist.`);
process.exit(1);
}
}

add('./sample/src/readme.md')

После запуска add.js и добавления файла из исходного кода в репозитории объектов должен появиться файл следующего вида.

Так должна выглядеть папка Gitj

Коммит изменений

Commit (коммит) сам по себе также является типом объекта и должен создать дополнительные файлы в папке objects.

Смысл коммита заключается в том, чтобы создать указатель на текущую ситуацию (файлы и папки), на которую мы можем указать и в будущем вернуться к этому состоянию.

Объект commit в Git содержит следующую информацию.

  • Автор: человек, внесший изменения.
  • Коммиттер: человек, закоммитивший изменения (иногда бывает так, что вы получаете патч от другого специалиста и должны закоммитить изменения).
  • Дата коммита.
  • Описание коммита.
  • Дерево (tree). Дерево само по себе является еще одним объектом, сохраняющим форму рабочей директории на момент создания.
  • Родитель (если существует).

Как выглядит Git-коммит

Если воспользоваться командой git log, мы увидим список хэшей коммитов, а если скопировать один из них, то можно выполнить команду git show --pretty=raw commitHash. Вот пример результата выполнения этой команды (дата представлена в виде временной метки после имени коммиттера и автора):

Результат Git Show Commit

В этом примере у нас есть родительский коммит, но если коммит является самым первым в истории, такового не будет.

Причина существования родительского коммита внутри объекта commit заключается в том, что таким образом мы можем связать эти коммиты в цепочку, и если мы вручную изменим коммит в прошлом, произойдет сбой, поскольку все хэши коммитов должны быть пересчитаны. Для этого мы используем такие команды, как rebase.

Тип объекта tree (дерево) в Git

Это тип объекта, который хранит форму папок и файлов. Например, в примере с коммитом выше мы можем проверить объект tree и посмотреть, что в нем находится. Для просмотра tree нужно использовать команду git ls-tree treeHash. Пример:

Команда Git LS Tree

Это базовое дерево, которое содержит файлы и папки рабочей директории. Оно включает два типа: blob (файлы) и tree  —  дерево, указывающее на другой объект дерева, который в данном случае представляет собой подпапку.

Во втором столбце указан тип объекта, в третьем  —  SHA, а в последнем  —  имя файла или папки (учтите: отсутствие имени файла в blob помогает использовать одно и то же содержимое снова и снова). Единственное, что нам пока неизвестно,  —  это первый столбец. Первый столбец  —  это File Mode (режим файла). Режим файла определяет тип объекта (например, blob или tree) и его разрешения. Числа, стоящие впереди, например 040000 или 100644, обозначают режим файла в восьмеричной системе счисления. Наиболее распространенными режимами являются следующие.

  • 100644: указывает на обычный файл (blob) с правами чтения-записи.
  • 100755: указывает на исполняемый файл (blob) с правами чтения-записи-исполнения.
  • 040000: указывает на директорию (дерево).

Действия, необходимые для реализации функциональности коммита:

  • Создание дерева текущей рабочей директории.
  • Создание объекта commit.
  • Получение родительского коммита (Head). Если родительского нет, то обновление head (master) после этого коммита.

Мы создадим простое дерево, которое будет генерировать структуру файлов и папок подобно тому, как это делает Git.

Реализация функции Tree generator

Небольшая функция для получения режима файла каждого файла:

async function getTreeFileMode(fileType, fileOrFolder) {
const { mode } = await fs.stat(fileOrFolder);
return fileType === 'tree' ? '040000' : '100' + ((mode & parseInt("777", 8)).toString(8));
}

Функция получения хэша файла:

async function getHashOfFile(path) {
const content = await fs.readFile(path);
const hash = crypto.createHash("sha1");
hash.update(content);
const sha = hash.digest("hex");
return sha;
};

Функция main:

async function createTreeObjectsFromPaths(folderPath) {
let treeFileContent = '';
let treeHash = ''
// мы хотим создать объект tree, аналогичный git ls-tree
const listOfFilesAndFolders = await fs.readdir(folderPath, { withFileTypes: true });
// если это файл, нужно сохранить хэш файла; если это директория, то будем вызывать эту функцию рекурсивно
for (const fileOrFolder of listOfFilesAndFolders) {
const fileType = fileOrFolder.isDirectory() ? 'tree' : 'blob';
const fileName = fileOrFolder.name;
let fileHash = '';
if (fileType === 'tree') {
const treeHash = await createTreeObjectsFromPaths(`${folderPath}/${fileName}`);
fileHash = treeHash;
} else {
// здесь необходимо вычислить хэш файла
fileHash = await getHashOfFile(`${folderPath}/${fileName}`);
}
const fileMode = await getTreeFileMode(fileType, `${folderPath}/${fileName}`);
const fileModeAndName = `${fileMode} ${fileType} ${fileHash} ${fileName}`;
treeFileContent += fileModeAndName + '\n';
}
const hash = crypto.createHash("sha1");
hash.update(treeFileContent);
treeHash = hash.digest("hex");
// записать объект tree в папку objects
if (!await folderOrFileExist(`.gitj/objects/${treeHash.slice(0, 2)}`)) {
await fs.mkdir(`.gitj/objects/${treeHash.slice(0, 2)}`, { recursive: true });
}
if (await folderOrFileExist(`.gitj/objects/${treeHash.slice(0, 2)}/${treeHash.slice(2)}`)) {
// дерево с таким же содержимым уже существует
console.log(`.gitj/objects/${treeHash.slice(0, 2)}/${treeHash.slice(2)}`);
return treeHash;
}
// запись файла в папку objects
await fs.writeFile(`.gitj/objects/${treeHash.slice(0, 2)}/${treeHash.slice(2)}`, treeFileContent);
// console.log(`.gitj/objects/${treeHash.slice(0, 2)}/${treeHash.slice(2)} \n`, treeFileContent);
return treeHash;
}

Пояснения к этой функции:

  1. Мы выполняем итерацию до результата fs.readdir.
  2. Если это директория, то тип  —  tree, и мы вызываем эту функцию рекурсивно. В противном случае это файл или, другими словами, blob, и нам нужно вычислить хэш.
  3. Получаем режим файла (040000, 100644…), так как он нужен для создания объекта tree.
  4. У нас есть содержимое объекта tree. Теперь можем создать хэш.
  5. Если объект (хэш текущего дерева) существует, то ничего не нужно делать, в противном случае создаем объект и сохраняем его в папке objects.

Запустим эту функцию и посмотрим, правильно ли она работает.

Структура папки выглядит следующим образом:

Структура папки

Теперь выполним команду createTreeObjectsFromPaths('.'). В результате в папке Gitj будут созданы два новых объекта:

Два новых объекта после создания объекта tree

Мы ожидаем, что в содержимом одного из этих объектов будет package.json, который находился в корневой папке в виде типа blob, и ожидаем также другой объект tree, указывающий на папку src:

Объект tree корневой папки

Теперь этот хэш коммита tree ссылается на другой объект, хранящий структуру папки src:

Объект tree папки src

Пришло время реализовать функцию Commit.

const fs = require('fs').promises;
const crypto = require('crypto');
const { createTreeObjectsFromPaths, folderOrFileExist } = require('./tree');

async function commit(commitMessage) {
const treeHash = await createTreeObjectsFromPaths('./sample');
const parentHash = await getLatestCommitHash();
const author = 'test';
const committer = 'test';
const commitDate = Date.now();
const commitContent = `tree ${treeHash}\nparent ${parentHash}\nauthor ${author}\ncommitter ${committer}\ncommit date ${commitDate}\n${commitMessage}`;
const hash = crypto.createHash("sha1");
hash.update(commitContent);
const commitHash = hash.digest("hex");
// запись объекта commit в папку objects
if (!await folderOrFileExist(`.gitj/objects/${commitHash.slice(0, 2)}`)) {
await fs.mkdir(`.gitj/objects/${commitHash.slice(0, 2)}`, { recursive: true });
}
if (await folderOrFileExist(`.gitj/objects/${commitHash.slice(0, 2)}/${commitHash.slice(2)}`)) {
// коммит с таким же содержанием уже существует
console.log(`.gitj/objects/${commitHash.slice(0, 2)}/${commitHash.slice(2)}`);
return commitHash;
}
// запись файла в папку objects
await fs.writeFile(`.gitj/objects/${commitHash.slice(0, 2)}/${commitHash.slice(2)}`, commitContent);
// установка головы текущей ветви в хэш коммита
await fs.writeFile('.gitj/refs/heads/master', commitHash);
return commitHash;
}

Самой сложной частью был объект tree. Теперь у нас есть все данные, мы собираем их вместе и создаем новый объект для коммита. Кроме того, нам нужно обновить голову ветви и указать на новый снимок, который мы получили (хэш коммита).

Команда git checkout

Что означают ветви Master, Main и другие?

Ветви  —  это просто ссылка на коммит или закладка для него. В процессе реализации коммита вы видели, что с момента обновления содержимого файла refs/head/master  —  это просто хэш коммита. У этого хэша коммита есть родитель (если это не первый коммит), и мы можем возвращаться к истории до тех пор, пока не останется ни одного предшествующего коммита. Таким образом, используя это имя ветви, мы можем получить доступ к последнему коммиту (Head или голове). Проще говоря, пребывание в ветви означает, что вы указываете на другую голову ветви.

Поскольку имя файла не хранится в blob, то преимуществом является то, что Git может использовать blob, даже если имена файлов различаются.

Как реализовать команду git checkout

Поскольку мы уже реализовали функциональность коммита, мы знаем, что в коммите мы имеем доступ к объекту tree (рекурсивно ко всем папкам и файлам) и располагаем файлами вида blob в .git (в нашем случае .gitj). Поэтому мы должны удалить рабочую директорию, а затем собрать всю директорию, когда кто-то другой проводит команду checkout относительно другого коммита (или ветви  —  голова ветви указывает на хэш коммита). Но прежде всего нужно сохранить хэш коммита или имя ветви в файле HEAD.

Перед этим необходимо реализовать небольшую функцию для получения объекта tree этого коммита:

async function getTreeHashFromCommit(commitHash) {
const commitContent = await fs.readFile(`.gitj/objects/${commitHash.slice(0, 2)}/${commitHash.slice(2)}`, 'utf-8');
const array = commitContent.split('\n').map(e=> e.split(' '))
const elem = array.find(e => e[1] === 'tree');
return elem[2];
};

Теперь нужно перестроить всю папку:

async function checkout(commitHash) {
const listOfFilesToCreate = [];
// сохранение хэша коммита в папке refs
await fs.writeFile('.gitj/HEAD', commitHash);
const treeHash = await getTreeHashFromCommit(commitHash);
// получение файла tree
const baseTree = await convertTreeObject(treeHash);
// очистка папки
await removeAllFilesAndFolders(folderPath);
// создание файлов и папок на основании адреса в blob
await createFilesAndFolders(baseTree, folderPath);
}

Сначала мы записываем этот коммит в файл HEAD, чтобы не забыть о том, что “голова” больше не находится в master. Мы должны рекурсивно получить дерево и его blob-файлы, чтобы пересоздать всю папку. Вот реализация:

async function convertTreeObject(treeHash, folderPrefix = '', files = []) {
const treeObject = await fs.readFile(`.gitj/objects/${treeHash.slice(0, 2)}/${treeHash.slice(2)}`, 'utf-8');
const array = treeObject.split('\n').map(e=> e.split(' '))
for (const file of array) {
if (!file || file.length < 2) continue;
const [mode, type, hash, name] = file;
if (type === 'tree') {
await convertTreeObject(hash, folderPrefix + name + '/', files);
} else {
files.push({
mode: mode,
type: type,
hash: hash,
name: folderPrefix + name
})
}
}
return files;
}

Если у нас есть файл, то мы добавляем имя файла и blob (содержимое файла) в массив. Если тип объекта  —  tree (дерево), то это означает, что мы имеем дело с папкой. Тогда нужно вызвать эту функцию рекурсивно, и мы должны указать путь к родительской папке, чтобы создать правильный путь к файлу.

Заключение

Мы узнали, как Git использует хэши и цепочку хэшей коммитов, чтобы отслеживать историю. Если вы заинтересованы в реализации дополнительных функций в Git, ознакомьтесь с этим репозиторием GitHub.

Ссылки:

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Poorshad Shaddel: Implement Git From Scratch using Javascript

Предыдущая статьяЧто такое сервер TURN?
Следующая статьяПроект инженерии данных «от и до» с Apache Airflow, Postgres и GCP