Профессиональные инженеры-разработчики используют Git ежедневно: это наиболее востребованное ПО контроля версий. Многие считают Git очень сложным. Но это не так. Git  —  большой программный компонент с обширным функционалом. И пришел я к такому выводу, когда наткнулся на курс Build your own Git на Codecrafters.

Задача показалась трудной. Но, изучив Git изнутри, я без проблем написал для него код. Поделюсь, что узнал об этой сложной теме, дабы ничего в работе Git вас не смущало.

Введение

Сначала изучим команды Git изнутри и покажем, как имитируется их поведение в коде. А затем протестируем, написав тестовые сценарии. Код напишем на Golang.

Не пишете ни на Go, ни на Python? Применяйте изученные концепции в любимом языке  —  логика Git универсальна. Рассмотрим три основные команды: git init, git cat-file, git hash-object.

Сфокусируемся на тестах для пользовательской реализации конкретных git-команд. Тесты для первых двух доступны в бесплатной пробной версии Codecrafters, подробно расскажем о написании тестов и реализации функциональности на Go дополнительных команд.

Что понадобится:

  • общее представление о Git;
  • знание любого языка программирования  —  не обязательно Go;
  • учетная запись GitHub для создания профиля на Codecrafters.

git init

Согласно официальной документации Git, командой git-init создается пустой репозиторий Git или переинициализируется имеющийся.

Когда запускается git init, в корневом каталоге проекта создается каталог .git, а не git: каталоги dot(.) считаются скрытыми. То есть каталог .git не отобразится ни в редакторе кода, ни при запуске команды ls в терминале.

Однако, если перейти в этот каталог, в нем обнаружатся другие подкаталоги: objects, refs/heads, refs/tags.

В objects хранятся большие двоичные объекты, созданные при закоммичивании файлов кода. Подробнее об этом каталоге  —  при разборе команд cat-file и hash-object.

В другом важном каталоге, refs, содержатся подкаталоги heads и tags, которые ссылаются на объекты коммитов и указывают на конкретные коммиты в истории репозитория.

Дерево файлов каталога .git

Перейдем к коду.

Сначала регистрируемся в этой задаче на Codecrafters и поэтапно настраиваем репозиторий. Пройдя начальные этапы задачи Codecrafters, получаем настроенный рабочий каталог. В коде заглядываем в файл cmd/mygit/main.go  —  код для команды init окажется закомментированным при начальной настройке.

Рассмотрим код команды init.

В функци main() имеется условие if, которым выбрасывается ошибка, когда длина os.Args меньше 2. По умолчанию, когда выполняется программа на Go, первый аргумент в списке os.Args  —  всегда сама программа. Это проверяется добавлением оператора print в начало функции main(), как здесь: fmt.Println(“ARGS:”, os.Args[0]).

При запуске тестов получается: ARGS: /var/folders/wm/28kg3mj97fd6_6dmw_ffytvw0000gn/T/go-build87834999/b001/exe/main.

Так проясняется, что такое os.Args. Первый элемент  —  ему присваивается индекс 0  —  это название программы, второй элемент os.Args[1]  —  сама запускаемая команда, здесь это init. Из терминала программа запускается так: go run main.go init.

Перейдем к оператору ветвления.

init

for _, dir := range []string{".git", ".git/objects", ".git/refs"} {
if err := os.MkdirAll(dir, 0755);
err != nil {
fmt.Fprintf(os.Stderr, "Error creating directory: %s\n", err)
}
}

Сначала проходим циклом по списку строк, где содержатся все рассмотренные выше каталоги Git. Ключевым словом range перебирается массив, при этом возвращаются индекс _ и сам элемент, который сохраняется в переменной dir.
Сохранив его, создаем требуемые каталоги методом MkdirAll библиотеки std os.

Согласно официальной документации Go, этим MkdirAll создается путь с именем каталога и все необходимые предки, а возвращается значение nil или ошибка. Perm битов полномочий перед umask используются для всех каталогов, создаемых в MkdirAll. Если путь уже каталог, в MkdirAll ничего не делается и возвращается значение nil.

Если Go установлен, командой вида go doc <название библиотеки> <название функции> в терминале получаются документы. Например, этой go doc os MkdirAll на стандартный вывод терминала выводится приведенное выше определение.

Второй принимаемый в MkdirAll аргумент  —  бит доступа к файлу. Разрешением 0755 в Linux владельцу файла или каталога дается возможность считывать, записывать и выполнять его. Другими пользователями файл или каталог считывается и запускается, но не изменяется и не удаляется.

headFileContents := []byte("ref: refs/heads/main\n")
if err := os.WriteFile(".git/HEAD", headFileContents, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %s\n", err)
}

fmt.Println("Initialized git directory")

Теперь записываем ветку репозитория по умолчанию в файле HEAD. В задаче CodeCrafter она или main, или master. Методом WriteFile из стандартной os lib записываем содержимое в файле с битом полномочий 0644. То есть владелец считывает и записывает в этот файл, другие же только считывают.

Наконец, завершаем этот оператор ветвления, выводя инструкцию Initialized git directory.

Отправив приведенный выше код, пройдем все требуемые тестовые сценарии.

Большие двоичные объекты

Прежде чем переходить к команде cat-file, важно разобраться, как в Git управляются и хранятся данные или файлы проекта. По сути, Git  —  это адресуемая по содержимому файловая система. Она представляется в виде простого хранилища данных «ключ-значение», благодаря которому в репозиторий Git вставляется любое содержимое. Взамен в Git предоставляется уникальный ключ, по которому потом это конкретное содержимое извлекается.
Он генерируется при передаче специально форматированной строки через алгоритм SHA-1. В строку включается фактическое содержимое файла в виде байтового массива, предваряемого словом blob, длиной содержимого в байтах и нулевым байтом. Вот структура этой строки:
blob <length-of-content>\x00 <content>

Результат  —  160-битное, или 20-байтовое, хеш-значение  —  это дайджест сообщений. Этот хеш представлен 40-значным шестнадцатеричным числом, которым однозначно идентифицируется содержимое хранилища.
Теперь первые два символа из 40-значного хеша становятся каталогом внутри git/objects, а следующие 38 символов  —  названием сжатого файла из проекта.

Например, если в проекте имеется файл .txt с содержимым Hello, World!, формат строки перед хешем будет blob 13\x00 Hello, World! 
Вернется хеш b45ef6fec89518d314f546fd6c3025367b721684. То есть путь к этому файлу будет .git/objects/b4/5ef6fec89518d314f546fd6c3025367b721684.

Дерево файлов каталога .git для примера выше

Изучив большие двоичные объекты, включая способы создания хешей и организации каталогов, мы разобрались с основными компонентами хранилища объектов Git. Эти знания пригодятся при работе с командой cat-file, которой осуществляются взаимодействия с этими объектами и просматривается их содержимое.

cat-file

Следующая задача Codecrafter  —  считать большой двоичный объект. На этом этапе командой git cat-file добавим поддержку считывания больших двоичных объектов.

Подробнее о git cat-file  —  здесь.

Согласно описанию тестов под задачей, код протестируется, чтобы:

  1. Тестировщиком с помощью программы сначала инициализировать новый репозиторий git, а затем в каталог .git/objects вставить большой двоичный объект со случайным содержимым.
  2. Тестировщиком проверить соответствие выходных данных программы содержимому большого двоичного объекта.

Программа запустится тестом так:

$ /path/to/your_program.sh cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad

В команде виден хеш файла, содержимое которого необходимо вывести. Флагом -p указывается, что содержимое файла структурированно выводится на стандартный вывод.

Перейдем к коду.

// Здесь реализуется команда «cat-file»
// Проверяется неравенство len(args) < 4
if len(os.Args) != 4 {
fmt.Fprintf(os.Stderr, "usage: mygit cat-file -p <object-hash>\n")
os.Exit(1)
}

Сначала проверяется, равна ли 4 длина os.Args. Если нет, выбрасывается ошибка. Первый элемент в списке os.Args по умолчанию  —  само название программы, за которым располагаются 
cat-file, -p и <hash>:

// Проверяется, не является ли третий аргумент аргумент «-p», а четвертый - допустимым хеш-объектом
readFlag := os.Args[2]
objectHash := os.Args[3]
if readFlag != "-p" && len(objectHash) != 40 {
fmt.Fprintf(os.Stderr, "usage: mygit cat-file -p <object-hash>\n")
os.Exit(1)
}

// Создается путь к файлу
dirName := objectHash[0:2]
fileName := objectHash[2:]
filePath := fmt.Sprintf("./.git/objects/%s/%s", dirName, fileName)

Здесь проверяется, допустимы ли третий и четвертый аргументы в списке. После этого создается путь к файлу из указанного хеша.

// Считывается файл
fileContents, err := os.ReadFile(filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %s\n", err)
os.Exit(1)
}

Затем методом ReadFile библиотеки std os lib считывается содержимое сжатого файла.

Согласно официальной документации Go, в ReadFile считывается именованный файл и возвращается содержимое. При успешном вызове возвращается err == nil, в противном случае  —  err == EOF.

// Содержимое файла распаковывается
b := bytes.NewReader(fileContents)
r, err := zlib.NewReader(b)
if err != nil {
fmt.Fprintf(os.Stderr, "Error decompressing the file: %s\n", err)
os.Exit(1)
}

decompressedData, err := io.ReadAll(r)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading decompressed data: %s\n", err)
os.Exit(1)
}
r.Close()

Следующий этап  —  распаковывание содержимого файла при помощи стандартной библиотеки compress/zlib из модулей Go. Продолжаем выбрасывать ошибки на каждом этапе и при необходимости завершаем работу.

// Находится индекс нулевого оконечного символа
nullIndex := bytes.IndexByte(decompressedData, 0)
if nullIndex == -1 {
fmt.Fprintf(os.Stderr, "Invalid object format: missing metadata separator\n")
os.Exit(1)
}

// Извлекается и выводится фактическое содержимое — все после нулевого байта
content := decompressedData[nullIndex+1:]
fmt.Print(string(content))

Последний этап  —  нахождение индекса нулевого байта в распакованном содержимом, а затем вывод всего после нулевого байта, потому что у содержимого каждого файла в начале имеется префикс blob <length-of-content>\x00.

Отправив этот код, пройдем тестовые сценарии.

gotest

Почему я решил написать тесты для следующей команды? Дело в том, что в Codecrafters имеется план «премиум», где доступны остальные задачи. Но, когда я писал статью, не был на него подписан. Поэтому, чтобы не ждать, написал собственные тесты. 
С подпиской «премиум» на Codecrafters задача выполняется без написания тестов.

Чтобы начать писать тесты на Go, рекомендую этот ресурс: рассмотрения основ будет достаточно, в целом это отличное подспорье для изучения Go.

Перейдем к описанию следующей задачи: Create a blob object. На этом этапе командой git hash-object создается большой двоичный объект и вычисляется SHA-хеш объекта Git. А при использовании с флагом -w объект также записывается ею в каталог .git/objects.

Подробнее о команде git hash-object  —  здесь.

Тесты

Тестами проверяется:

  • вывод программой 40-символьного SHA-хеша на стандартный вывод;
  • что файл записан в .git/objects в соответствии с написанным в официальной реализации git.

Для первого теста создается файл text.txt с контентом внутри. Затем в файле запускается сама команда Git git hash-object -w text.txt вместе с реализацией той же команды go run main.go hash-object -w text.txt.

Теоретически обеими этими командами генерируется один и тот же хеш. Поэтому, чтобы определить, выполнился тестовый сценарий или нет, сравним их выводы.

Затем напишем второй тест и проверим, создан ли большой двоичный объект в правильном месте. Теоретически большой двоичный объект создается кодом внутри .git/objects, однако для целей тестирования создадим этот объект в другом месте, например в .mygit/objects. Ведь если сохранить большой двоичный объект с таким же хешем в папке .git, процесс будет пропущен: файл с этим же хешем уже должен существовать. Поэтому, чтобы проверить, обходится ли реализация без неожиданностей, объект сохраняется в другом месте.

Наконец, чтобы проверить, совпадает ли содержимое большого двоичного объекта и файла, пишем третий тест.

Перейдем к коду для первого теста:

func TestHashObject (t *testing.T) {
// Создается файл с содержимым
fileName := "text.txt"
fileContents := []byte("Hello, World!")

if err := os.WriteFile(fileName, fileContents, 0644); err != nil {
t.Fatalf("Error writing to test file: %s\n", err)
}

// Сюда добавляются дополнительные фрагменты кода
}

Начинаем с создания файла text.txt в тестовой функции.

// Код, добавляемый в тестовую функцию
// Выполняется команда «git hash-object»
wantHash, gitErr := RunGitHashObject(fileName)
if gitErr != nil {
t.Fatalf("Error implementing git command: %s\n", gitErr)
}

Здесь вызываем функцию, в которой выполняется команда git hash-object над текстовым файлом и возвращается хеш, получаемый после сохранения большого двоичного объекта файла в .git/objects. Потом сравним эту переменную wantHash со сгенерированным в коде хешем gotHash.

Вот определение функции:

// Функция, в которой выполняется команда «git hash-object»
func RunGitHashObject(filePath string) (string, error) {
cmd := exec.Command("git", "hash-object", "-w", filePath)

// Получается на стандартный вывод
var out bytes.Buffer
cmd.Stdout = &out

// Выполняется команда
err := cmd.Run()
if err != nil {
return "", err
}

// Возвращается хеш, пробелы удаляются
return out.String(), nil
}

В функции RunGitHashObject пакетом exec библиотеки std os выполняются внешние команды. Здесь команда выполняется функцией, получается стандартный вывод и возвращается строка.

Теперь вызовем аналогичную предыдущей функцию, которой выполняется реализация hash-object, переменную для получаемого хеша назовем gotHash.

// Код, добавляемый в тестовую функцию
// Вызываем основную функцию командой «hash-object»
gotHash, myGitHashObjectError := RunMainFuncWithHashObject(fileName)
if myGitHashObjectError != nil {
t.Fatalf("Error implementing mygit command: %s\n", myGitHashObjectError)
}
// Функция, в которой выполняется команда «go run main.go hash-object»
func RunMainFuncWithHashObject(fileName string) (string, error) {
cmd := exec.Command("go", "run", "main.go", "hash-object", "-w", fileName)

// Получается на стандартный вывод
var out bytes.Buffer
cmd.Stdout = &out

// Выполняется команда
err := cmd.Run()
if err != nil {
return "", err
}

// Возвращается хеш, пробелы удаляются
return out.String(), nil
}

Вот полная реализация команды hash-object. Код снабжен комментариями, чем облегчается его понимание. Вот этапы для самостоятельной реализации команды:

  1. Проверяем корректность всех аргументов.
  2. Считываем содержимое файла.
  3. Генерируем хеш, используя описанный выше синтаксис  —  с SHA1.
  4. Записываем сжатое содержимое большого двоичного объекта в генерируемый хешем путь к файлу, например: .mygit/objects/<first-two-hash-chars>/<remaining-hash> .
  5. Сгенерированный хеш получаем на стандартный вывод stdout.

Имея wantHash и gotHash, напишем первый тестовый сценарий и проверим, генерируется ли кодом и git один и тот же хеш:

t.Run("Testing Hash creation", func(t *testing.T) {
if gotHash != wantHash {
t.Errorf("got %q want %q", gotHash, wantHash)
}
})

Запускаем в терминале go test. Должно выполниться, для проверки тест специально ломается  —  меняется строка want.

Напишем второй тестовый сценарий и проверим, в правильном ли месте создан большой двоичный объект:

t.Run("Testing the blob object creation", func(t *testing.T) {
gotHash = strings.TrimSpace(gotHash)
filePath := fmt.Sprintf(".mygit/objects/%s/%s", gotHash[0:2], gotHash[2:])
// Файл считывается
_, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
fmt.Println("Error finding file: ", err)
os.Exit(1)
}
}
})

Снова запускаем go test в терминале. Если тест не выполняется, большой двоичный объект не создан в ожидаемом месте. Это проверяется и вручную, в файловом обозревателе.

Напишем третий, но не менее важный тестовый сценарий, в котором проверим, совпадает ли содержимое большого двоичного объекта и файла, для вывода содержимого большого двоичного объекта воспользуемся уже реализованной командой cat-file:

t.Run("Testing the contents of the blob", func(t *testing.T) {
// Вызываем основную функцию командой «cat-file»
gotContent, myGitCatFileError := RunMainFunctionCatFile(gotHash)
if myGitCatFileError != nil {
t.Fatalf("Error implementing mygit command: %s\n", myGitCatFileError)
}
gotContent = strings.TrimSpace(gotContent)
wantContent := string(fileContents)
if gotContent != wantContent {
t.Errorf("got %q want %q", gotContent, wantContent)
}
})

В последний раз запускаем go test в терминале, все тесты должны выполниться без ошибок.

В последнем тесте имеется интересный нюанс: запускаемой в сгенерированном хеше командой cat-file большой двоичный объект фактически продолжает считываться из .git/objects, а не из .mygit/objects. Тем не менее тестовый сценарий выполнился, потому что при добавлении git тестового файла в его каталог использовался тот же хеш. Это хорошее подспорье для проверки корректности реализации.

Заключение

Мы реализовали основную функциональность Git: hash-object, cat-file, init. А также разобрались, как в Git управляются объекты. Хотя это только вершина айсберга, отсюда можно копать дальше, реализовывая следующие этапы этой задачи.

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

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


Перевод статьи Sarthak Duggal: Building Your Own Git from Scratch in Go

Предыдущая статьяKotlin: изолированные классы и интерфейс