Зачем нужен Git и как он устроен

Зачем нужен Git и как он устроен

Любой проект, в котором работает больше одного человека (а часто и один), рано или поздно сталкивается с тремя вопросами: «что изменилось?», «когда это сломалось?» и «кто это сделал?». Система контроля версий отвечает на все три. Git - самая распространённая из таких систем, и именно с ней ты будешь работать каждый день как бэкенд-разработчик.

Без Git невозможны code review, CI/CD, параллельная командная работа, откат неудачного релиза и десятки других вещей, которые считаются стандартом индустрии. Понимание того, как Git устроен внутри, помогает не бояться его команд и быстро разбираться в нетривиальных ситуациях.

Централизованные и распределённые VCS

До Git были системы вроде SVN (Subversion) и CVS. Они работали по модели «один центральный сервер»: вся история хранилась на сервере, а разработчик получал только текущую версию файлов. Если сервер падал - история была недоступна.

Git - распределённая система. Каждый разработчик хранит у себя полную копию репозитория со всей историей. Это даёт несколько преимуществ:

  • работа оффлайн - коммиты, просмотр истории, создание веток не требуют сети;
  • скорость - почти все операции локальные;
  • надёжность - потеря сервера не означает потерю истории, потому что у каждого участника есть полная копия;
  • гибкий workflow - ветки стоят дёшево, merge и rebase делаются локально.

Централизованная VCS против распределённой: SVN с одним сервером и Git с полными копиями у каждого

В централизованной модели разработчики - тонкие клиенты, а сервер - единственный держатель истории. В Git каждый узел равноправен: твой ноутбук, ноутбук коллеги и сервер GitHub - это три одинаково полноценные копии репозитория. Remote (GitHub/GitLab) - не «главный сервер», а просто общая точка синхронизации.

Объектная модель Git

Git хранит данные не как «список изменений», а как граф объектов. Есть четыре типа объектов:

blob - содержимое одного файла. Git не хранит имя файла в blob, только содержимое. Два файла с одинаковым содержимым - один blob.

tree - аналог директории. Содержит ссылки на blob-ы (файлы) и другие tree (поддиректории), плюс имена и права доступа.

commit - снимок проекта. Содержит ссылку на корневой tree, имя автора, дату, сообщение коммита и ссылку на родительский коммит (или несколько при merge).

tag - именованная ссылка на коммит (обычно для версий: v1.0.0, v2.3.1).

Объектная модель Git: commit ссылается на корневой tree и parent commit, tree содержит blob-файлы и вложенные tree

Каждый объект идентифицируется SHA-1 хешем своего содержимого. Это значит, что одинаковое содержимое всегда даёт один и тот же хеш, а любое изменение - другой. Git по сути является content-addressable storage.

Когда ты понимаешь, что коммит - это снимок (tree), а не diff, становится ясно, почему Git так быстро переключает ветки: он просто подменяет рабочую директорию содержимым нужного tree.

Снимок, а не diff: почему это не мелочь

Формулировка «коммит хранит изменения» звучит естественно, но она неверна и приводит к неверным ожиданиям. Коммит хранит полный снимок проекта: ссылку на корневой tree, который перечисляет все файлы на тот момент. Diff, который ты видишь в git show, вычисляется на лету сравнением двух снимков.

Из этого следуют вещи, которые иначе выглядят необъяснимыми:

  • История не «накапливает» долг. Чтобы получить состояние файла на коммите из 2019 года, Git не проигрывает 5000 патчей подряд - он берёт один tree и один blob. Поэтому git checkout старого коммита такой же быстрый, как свежего.
  • Одинаковые файлы не дублируются. Файл, не менявшийся 200 коммитов, - это один blob, на который ссылаются 200 tree. Репозиторий не растёт от того, что ты коммитишь часто.
  • Переименование Git не хранит. В снимке просто нет файла old.go и есть файл new.go. «Переименование» в выводе git log --follow - это эвристика по схожести содержимого, а не записанный факт. Отсюда странности: переименовал и сильно переписал файл за один коммит - Git покажет удаление плюс добавление.
Дельты в Git есть, но живут на другом уровне. При упаковке (`git gc`, `git push`) объекты складываются в packfile, и внутри него похожие blob-ы хранятся как дельты друг от друга. Это оптимизация **хранения**, невидимая для модели данных: любой объект по-прежнему адресуется своим хешем и восстанавливается целиком. Поэтому `.git` тысячи коммитов часто занимает меньше, чем один рабочий каталог.

Целостность: почему хеш - это ещё и подпись

Хеш коммита считается от его содержимого, а в содержимое входит хеш родителя. Родитель, в свою очередь, включает хеш своего родителя - и так до первого коммита. Получается цепочка, где нельзя подменить старый коммит, не изменив хеши всех потомков.

git cat-file -p HEAD
# tree 9f2c1a4b7e...
# parent 3a7f1b2c8d...            <- хеш родителя лежит ВНУТРИ коммита
# author Dmitriy <mail@example.com> 1755300000 +0300
# committer Dmitriy <mail@example.com> 1755300000 +0300
#
# feat(auth): add JWT middleware

Здесь же ответ на вопрос, который позже возникнет в уроке про merge и rebase: почему после правки истории хеши меняются. Изменил сообщение через --amend, переставил коммиты через rebase, поменял автора - меняется содержимое коммита, значит меняется его хеш. А раз изменился хеш, то у следующего коммита изменился parent, значит изменился и он. Каскад идёт до конца ветки.

Отсюда два практических правила, которые в треке будут повторяться:

  • rebase и amend не «редактируют» коммиты, а создают новые. Старые остаются в reflog, пока их не собрал сборщик мусора;
  • переписывать уже опубликованную историю нельзя без договорённости: коллеги держат у себя старые хеши, и их ветки после твоего push --force начнут расходиться с remote.
Git исторически использует SHA-1, для которого в 2017 году показали практическую коллизию. Для Git это менее критично, чем звучит: хешируется не только содержимое файла, но и тип с длиной объекта, а серверы дополнительно проверяют подозрительные объекты. Тем не менее в новых версиях Git есть поддержка SHA-256 для репозиториев, создаваемых с флагом `--object-format=sha256`. В повседневной работе ты продолжишь видеть 40-символьные SHA-1 хеши.

Три зоны: working directory, staging area, repository

Git разделяет работу на три зоны, и это ключевая ментальная модель:

Три зоны Git: working directory, staging area и repository со стрелками git add, git commit и обратной git checkout

Working directory - файлы, которые ты видишь в файловом менеджере. Здесь ты пишешь код.

Staging area (index) - промежуточная зона. Ты выбираешь, какие именно изменения попадут в следующий коммит. Это позволяет коммитить не всё подряд, а логически связанные изменения.

Repository - папка .git/, где хранятся все объекты, ветки, конфиг. Коммит фиксирует то, что было в staging area.

Зачем вообще нужен индекс

Законный вопрос новичка: если я всё равно каждый раз пишу git add ., зачем эта лишняя зона? У индекса четыре роли, и все четыре рабочие.

Первая: коммит - это ты решаешь, что в нём. За рабочий час ты правишь баг, попутно чистишь импорты и меняешь конфиг. Одним коммитом это плохо: ревьюеру придётся распутывать три темы, а git revert откатит всё три сразу. Индекс позволяет собрать коммит по частям, вплоть до отдельных строк одного файла через git add -p - подробно в уроке про базовый цикл работы.

Вторая: индекс - это черновик, который можно проверить. git diff показывает то, что ещё не в индексе, git diff --staged - то, что уже там. Перед коммитом видно ровно то, что будет зафиксировано, и забытый fmt.Println("test") или закомментированная проверка ловятся до, а не после ревью.

Третья: индекс - кеш производительности. Файл .git/index хранит не только список путей, но и метаданные каждого файла (размер, время изменения, inode). Поэтому git status в репозитории с 50 тысячами файлов не читает их содержимое: он сравнивает метаданные и хеширует только то, что выглядит изменённым. Без индекса каждый git status был бы полным обходом проекта.

Четвёртая: индекс - рабочее место при конфликтах. Во время слияния Git держит в индексе сразу три версии конфликтного файла: общего предка, «нашу» и «их». Именно поэтому git add на конфликтном файле означает «конфликт решён» - ты заменяешь три записи одной. Механика разобрана в уроке про конфликты.

`.git/index` не текстовый, руками его не читают. Посмотреть содержимое можно через plumbing-команду: `git ls-files -s` покажет режим, хеш blob-а и stage-номер для каждого файла. При конфликте в колонке stage будут стоять 1, 2 и 3 вместо обычного 0 - те самые три версии.

Структура папки .git/

Когда ты делаешь git init, в проекте появляется скрытая папка .git/. Вот что внутри:

Структура папки .git: HEAD, config, objects, refs/heads, refs/remotes, hooks, index, logs и их назначение

.git/
├── HEAD              текстовый файл: на какую ветку ты сейчас смотришь
├── config            настройки этого репозитория (remote, user.email)
├── index             бинарный индекс - staging area
├── objects/          все blob, tree, commit, tag; по первым 2 символам хеша
│   ├── 3a/7f1b2c8d...
│   └── pack/         упакованные объекты (packfile) после git gc
├── refs/
│   ├── heads/        локальные ветки: файл на ветку, внутри хеш коммита
│   └── remotes/      что мы знаем о ветках на remote
├── logs/             reflog: журнал перемещений HEAD и ветвей
└── hooks/            скрипты pre-commit, pre-push (по умолчанию примеры .sample)

Самое полезное упражнение первого дня - убедиться, что здесь нет магии. Всё это обычные файлы:

cat .git/HEAD
# ref: refs/heads/main            <- HEAD не хранит хеш, он ссылается на ветку

cat .git/refs/heads/main
# 3a7f1b2c8d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a    <- ветка = одна строка с хешем

git rev-parse HEAD                # то же самое, но правильным способом
git cat-file -t 3a7f1b2          # тип объекта: commit
git cat-file -p 3a7f1b2          # содержимое объекта
git ls-tree HEAD                 # что лежит в корневом tree коммита
# 100644 blob a1b2c3d...    go.mod
# 040000 tree d4e5f6a...    internal

Именно поэтому «ветка» в Git - это дешёвая операция: создать ветку значит записать файл из 41 байта. И поэтому git checkout в другую ветку меняет .git/HEAD и раскладывает нужный tree, а не копирует историю.

Можно зайти и с другой стороны - положить объект в базу руками:

echo "hello git" | git hash-object -w --stdin
# 8d0e412f2ad4b2f2d3a4c5b6...     <- объект записан в .git/objects
git cat-file -p 8d0e412           # hello git

Так становится видно главное: Git - это key-value хранилище, где ключ есть хеш содержимого, плюс тонкий слой команд поверх. Всё остальное в треке - ветки, теги, поиск по истории - надстройки над этими четырьмя типами объектов.

Папка `.git/` - это весь репозиторий. Удалишь её - потеряешь всю историю. Файлы на диске останутся, но Git перестанет их отслеживать.

DAG: история как граф

История в Git - это направленный ациклический граф (DAG). Каждый коммит указывает на своего родителя (или родителей при merge). Ветки - это просто указатели (ссылки) на определённые коммиты.

DAG истории Git: ветка main A-B-C-D с веткой feature/login E-F, сходящейся в merge-коммит D

В этом примере коммит D - merge-коммит с двумя родителями: C и F. Ветка main указывает на D, ветка feature/login указывает на F. Стрелки в графе всегда смотрят от потомка к родителю - именно так Git ходит по истории при git log: от текущего коммита назад во времени.

Такая модель даёт Git возможность эффективно работать с ветками: создание ветки - это запись 41 байта (SHA-1 хеш + перенос строки), а не копирование файлов.

Локальность: что работает в самолёте

«Распределённая система» звучит абстрактно, пока не окажешься без интернета. Практический смысл прост: сеть нужна ровно четырём командам.

Работает офлайнТребует сети
git add, git commit, git status, git diffgit clone
git log, git show, git blame, git bisectgit fetch
git branch, git switch, git merge, git rebasegit pull (это fetch плюс merge)
git stash, git revert, git reset, git refloggit push
git tag (создать локально)git push --tags (опубликовать)

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

Коммитить можно и нужно часто. Коммит не отправляет ничего наружу, его никто не увидит и он ничего не сломает. Страх «закоммичу недоделанное» - наследие централизованных систем, где commit означал публикацию. В Git публикация - это push, и до него историю ещё можно почистить.

Разбор инцидента не зависит от доступности GitLab. git log, git blame и git bisect работают с локальной копией, поэтому найти коммит, сломавший тесты, можно и при упавшем хостинге.

Первый clone тяжёлый, дальше дешёво. Клонирование монорепозитория тянет всю историю целиком, отсюда сотни мегабайт на «маленький» проект. Если нужна только свежая версия для CI, историю не тянут:

git clone --depth 1 <url>              # shallow clone: только последний коммит
git clone --filter=blob:none <url>      # без старых версий файлов, догрузит по требованию

Именно --depth 1 стоит в большинстве CI-пайплайнов: сборке не нужна история за пять лет, а разница во времени клонирования - минуты.

Полная копия истории у каждого разработчика создаёт обратную проблему: если ты закоммитил `.env` с продовым паролем и запушил, удалить его из истории у всех уже нельзя - копии разошлись по машинам и по CI-кешам. Секрет считается скомпрометированным с момента push, и правильное действие - ротация ключа, а не переписывание истории. Как не допустить этого, разбираем в уроке про [.gitignore](./10-ignore.md).

Зачем VCS бэкенд-разработчику

На первый взгляд, контроль версий - это про «сохранение файлов». Но в реальной работе Git решает задачи, которые без него были бы мучительными:

Откат неудачного деплоя. Релиз ушёл в прод и сломал авторизацию. Без Git - паника, попытки вспомнить, что изменилось. С Git - git revert <hash> или деплой предыдущего тега, и через 2 минуты прод снова работает.

Параллельная работа. Три разработчика одновременно пилят три фичи в одном микросервисе. Каждый в своей ветке, код не мешает друг другу, merge request собирает изменения вместе.

Code review. Merge Request в GitLab/GitHub показывает ровно те строки, которые изменились. Ревьюер читает diff, оставляет комментарии, автор исправляет - и всё это отслеживается.

CI/CD. Pipeline запускается автоматически при push: линтеры, тесты, сборка Docker-образа, деплой. Без Git нет push, без push нет CI.

Аудит. git blame показывает, кто написал каждую строку. git log --follow показывает историю файла, даже если его переименовали. git bisect находит коммит, который сломал тесты, бинарным поиском.

Git ≠ GitHub

Git - инструмент, работающий локально на твоём компьютере. GitHub, GitLab, Bitbucket - это хостинги для удалённых репозиториев, которые добавляют веб-интерфейс, pull/merge requests, CI/CD, issue tracker и другие фичи поверх Git. Можно использовать Git без GitHub, но не GitHub без Git.

В реальной работе бэкенд-разработчика Git участвует в каждом этапе:

  • разработка - ветки, коммиты, локальное тестирование;
  • code review - merge/pull request на GitLab/GitHub;
  • CI/CD - pipeline запускается при push, собирает и деплоит;
  • откат - если релиз сломал прод, git revert или деплой предыдущего тега;
  • аудит - git blame и git log показывают, кто, когда и зачем менял код.

Каждый из этих пунктов разберём отдельно дальше по треку. Начинаем с настройки Git: имя, email и SSH-ключ, без которых первый же push упрётся в ошибку.

Мини-задание

  • Установи Git и проверь версию: git --version
  • Создай пустой репозиторий: git init test-repo && cd test-repo
  • Загляни внутрь папки .git/ и найди файл HEAD - посмотри, на что он указывает
  • Сделай первый коммит и посмотри, как изменилось содержимое .git/objects/
  • Выполни git cat-file -t <hash> и git cat-file -p <hash> для любого объекта, чтобы увидеть его тип и содержимое
  • Сравни cat .git/refs/heads/main и git rev-parse HEAD - убедись, что ветка это просто файл с хешем
  • Положи объект в базу руками: echo "test" | git hash-object -w --stdin, потом прочитай его через git cat-file -p
  • Посмотри содержимое индекса: git ls-files -s - найди хеши blob-ов своих файлов
  • Сделай git commit --amend -m "новое сообщение" и сравни хеш до и после через git rev-parse HEAD
  • Отключи сеть и убедись, что git log, git commit и git branch продолжают работать

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.