Зачем нужен Git и как он устроен
Зачем нужен Git и как он устроен
Любой проект, в котором работает больше одного человека (а часто и один), рано или поздно сталкивается с тремя вопросами: «что изменилось?», «когда это сломалось?» и «кто это сделал?». Система контроля версий отвечает на все три. Git - самая распространённая из таких систем, и именно с ней ты будешь работать каждый день как бэкенд-разработчик.
Без Git невозможны code review, CI/CD, параллельная командная работа, откат неудачного релиза и десятки других вещей, которые считаются стандартом индустрии. Понимание того, как Git устроен внутри, помогает не бояться его команд и быстро разбираться в нетривиальных ситуациях.
Централизованные и распределённые VCS
До Git были системы вроде SVN (Subversion) и CVS. Они работали по модели «один центральный сервер»: вся история хранилась на сервере, а разработчик получал только текущую версию файлов. Если сервер падал - история была недоступна.
Git - распределённая система. Каждый разработчик хранит у себя полную копию репозитория со всей историей. Это даёт несколько преимуществ:
- работа оффлайн - коммиты, просмотр истории, создание веток не требуют сети;
- скорость - почти все операции локальные;
- надёжность - потеря сервера не означает потерю истории, потому что у каждого участника есть полная копия;
- гибкий workflow - ветки стоят дёшево, merge и rebase делаются локально.
В централизованной модели разработчики - тонкие клиенты, а сервер - единственный держатель истории. В Git каждый узел равноправен: твой ноутбук, ноутбук коллеги и сервер GitHub - это три одинаково полноценные копии репозитория. Remote (GitHub/GitLab) - не «главный сервер», а просто общая точка синхронизации.
Объектная модель Git
Git хранит данные не как «список изменений», а как граф объектов. Есть четыре типа объектов:
blob - содержимое одного файла. Git не хранит имя файла в blob, только содержимое. Два файла с одинаковым содержимым - один blob.
tree - аналог директории. Содержит ссылки на blob-ы (файлы) и другие tree (поддиректории), плюс имена и права доступа.
commit - снимок проекта. Содержит ссылку на корневой tree, имя автора, дату, сообщение коммита и ссылку на родительский коммит (или несколько при merge).
tag - именованная ссылка на коммит (обычно для версий: v1.0.0, v2.3.1).
Каждый объект идентифицируется SHA-1 хешем своего содержимого. Это значит, что одинаковое содержимое всегда даёт один и тот же хеш, а любое изменение - другой. Git по сути является content-addressable storage.
Снимок, а не 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 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.
Три зоны: working directory, staging area, repository
Git разделяет работу на три зоны, и это ключевая ментальная модель:
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/
Когда ты делаешь git init, в проекте появляется скрытая папка .git/. Вот что внутри:
.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 хранилище, где ключ есть хеш содержимого, плюс тонкий слой команд поверх. Всё остальное в треке - ветки, теги, поиск по истории - надстройки над этими четырьмя типами объектов.
DAG: история как граф
История в Git - это направленный ациклический граф (DAG). Каждый коммит указывает на своего родителя (или родителей при merge). Ветки - это просто указатели (ссылки) на определённые коммиты.
В этом примере коммит D - merge-коммит с двумя родителями: C и F. Ветка main указывает на D, ветка feature/login указывает на F. Стрелки в графе всегда смотрят от потомка к родителю - именно так Git ходит по истории при git log: от текущего коммита назад во времени.
Такая модель даёт Git возможность эффективно работать с ветками: создание ветки - это запись 41 байта (SHA-1 хеш + перенос строки), а не копирование файлов.
Локальность: что работает в самолёте
«Распределённая система» звучит абстрактно, пока не окажешься без интернета. Практический смысл прост: сеть нужна ровно четырём командам.
| Работает офлайн | Требует сети |
|---|---|
git add, git commit, git status, git diff | git clone |
git log, git show, git blame, git bisect | git fetch |
git branch, git switch, git merge, git rebase | git pull (это fetch плюс merge) |
git stash, git revert, git reset, git reflog | git 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-пайплайнов: сборке не нужна история за пять лет, а разница во времени клонирования - минуты.
Зачем 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 участвует в каждом этапе:
- разработка - ветки, коммиты, локальное тестирование;
- 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продолжают работать