Снимки, а не диффы
Git хранит каждый коммит как полный снимок дерева проекта, а не как цепочку диффов. Коммит указывает на дерево, дерево — на блобы, а неизменённые файлы переиспользуют тот же адресуемый по содержимому блоб — поэтому снимки дёшевы. Дифф вычисляется по запросу, а не хранится.
Большинство людей думают, что git хранит историю очевидным способом: держит первую версию, а потом на каждое изменение сохраняет маленький патч — «строка 7 поменялась с X на Y» — и складывает эти патчи стопкой. Именно так на самом деле работали старые системы вроде Subversion и CVS, и эта ментальная модель просачивается в то, как люди рассуждают о git. Она ещё и неверна, и из-за неверной модели скорость git выглядит как магия, а не как очевидное следствие его устройства.
К концу этого урока ты сможешь сказать, что git на самом деле хранит для каждого коммита — полный снимок проекта — и объяснить, почему это делает ветвление, переключение и слияние быстрыми, а не расточительными.
После этого урока ты сможешь объяснить, что коммит git хранит полный снимок дерева проекта (а не дифф), описать, как коммит указывает на дерево, которое указывает на адресуемые по содержимому блобы, сказать, почему одинаковое содержимое хранится лишь один раз, и объяснить, что git diff вычисляется по запросу сравнением двух снимков.
Коммит — это снимок всего проекта, а не запись того, что изменилось. Когда ты коммитишь, git не записывает «эти три строки поменялись». Он записывает, как выглядит каждый отслеживаемый файл в этот момент — всё дерево, сверху донизу. По сути каждый коммит — это фотография проекта. Старые системы (CVS, Subversion) хранят базовую версию плюс список пофайловых дельт и восстанавливают любую версию, проигрывая патчи. Git сделал противоположную ставку: хранить состояния, а различия выводить потом.
Снимок собирается из трёх типов объектов: коммит, дерево и блоб. Хранилище git — это небольшой граф объектов:
- Блоб хранит сырые байты содержимого одного файла. Только содержимое — без имени файла, без пути.
- Дерево — это листинг каталога: оно сопоставляет имена (
README.md,src/) с блобами и поддеревьями, которые они содержат. - Коммит указывает на одно дерево верхнего уровня (корневой снимок проекта) плюс метаданные: автор, сообщение и ссылку на родительский коммит.
Итак, коммит указывает на дерево, дерево указывает на блобы и другие деревья, и вместе они восстанавливают точное состояние проекта — без проигрывания патчей.
Каждый объект именуется хешем SHA-1 от своего содержимого — это адресация по содержимому. Git прогоняет хеш-функцию по байтам объекта и использует получившуюся 40-символьную шестнадцатеричную строку как имя и адрес объекта.
# git считает тот же хеш, что ты получишь через hash-object
echo 'hello' | git hash-object --stdin
# -> ce013625030ba8dba906f756967f9e9ca394464aКлючевое свойство: имя и есть отпечаток содержимого. Одинаковое содержимое всегда даёт одинаковый хеш. Поменяй один байт — и хеш меняется полностью. Адрес — это не место на диске, он выводится из того, что внутри.
▸Почему это работает
Адресация по содержимому — это и есть причина, почему «хранить полный снимок на каждый коммит» не расточительно. Если файл не менялся между двумя коммитами, его байты идентичны, значит, хеш его блоба идентичен, значит, деревья обоих коммитов указывают на один и тот же объект-блоб — git хранит это содержимое ровно один раз. Коммит, который трогает один файл в репозитории на 10 000 файлов, создаёт один новый блоб, несколько новых деревьев вдоль пути к этому файлу и один новый объект-коммит. Остальные 9 999 файлов разделяются по ссылке, а не копируются. Снимки дёшевы именно потому, что одинаковое содержимое дедуплицируется по своему хешу.
Поскольку git держит целые снимки, чтение любой версии — это прямой поиск, а не проигрывание. Чтобы переключиться на старый коммит, git читает дерево этого коммита и выписывает блобы, которые оно называет, — готово. Он никогда не идёт по цепочке патчей. Вот почему git checkout, ветвление и переключение точек истории ощущаются мгновенными: нет диффа, который надо применять, есть лишь снимок, который надо материализовать. Ветвление дёшево по той же причине — ветка это лишь указатель на один коммит (40-байтовый хеш), а не копия файлов.
Дифф — это вывод, вычисляемый по запросу, и никогда не формат хранения. Когда ты запускаешь git diff или git log -p, git берёт два снимка и считает различия между ними прямо тогда:
git diff HEAD~1 HEAD # вычислить изменения между двумя снимкамиПатч, который ты видишь, генерируется для твоих глаз, а не загружается с диска. Это переворачивает старые системы: они хранили диффы и вычисляли снимки; git хранит снимки и вычисляет диффы. Дифф — это представление, а не источник истины.
Одна правка в проекте из двух файлов.
В твоём репозитории есть README.md и app.js. Коммит A снимает оба: его дерево называет блоб A (содержимое README) и блоб B (содержимое app.js). Теперь ты чинишь опечатку в app.js и делаешь коммит B.
Что git на самом деле пишет для коммита B: новый блоб C (новые байты app.js — его хеш отличается, потому что поменялся один байт), новое дерево (README → блоб A, app.js → блоб C) и новый объект-коммит, указывающий на это дерево. Заметь: README.md не трогали, так что его содержимое и хеш не изменились — дерево коммита B переиспользует блоб A, тот самый объект, на который указывает коммит A. Git не копировал README и не хранил патч «опечатка исправлена». Он сохранил свежий снимок, единственное новое содержимое которого — один изменившийся файл; всё остальное разделяется по хешу. Когда ты позже запустишь git diff A B, git сравнит два дерева и вычислит для тебя ту самую однострочную правку на месте.
▸Частая ошибка
Распространённое заблуждение — «полные снимки на каждый коммит должны делать репозиторий огромным». Это не так из-за дедупликации: неизменённые файлы — это указатели на существующие блобы, а не копии. Репозиторий с тысячей коммитов не держит тысячу копий неизменённого файла лицензии — он держит один блоб, на который ссылаются тысячу раз. (Git позже ещё и упаковывает объекты с дельта-сжатием в хранилище, но это слой оптимизации внизу; логическая модель, с которой работают команды, — это всегда целые снимки.)
Что в git фундаментально хранит один коммит?
Git хранит каждый коммит как полный снимок проекта, а не как цепочку диффов. Коммит указывает на дерево, дерево указывает на блобы (содержимое файлов) и поддеревья, а каждый объект именуется хешем SHA от своего содержимого — это адресация по содержимому. Поскольку одинаковое содержимое даёт одинаковый хеш, неизменённые между коммитами файлы переиспользуют тот же блоб и хранятся лишь раз, так что снимки остаются дешёвыми. Чтение любой версии — это прямой поиск без проигрывания патчей, поэтому переключение, ветвление и слияние быстры. Дифф вычисляется по запросу сравнением двух снимков — это вывод, а не формат хранения. Дальше ты увидишь, где на самом деле живут твои файлы, пока ты работаешь: три области, через которые git проводит изменения.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.