3.1 База: Git как маленькая файловая система с хэшами
Чтобы понять Git, проще всего держать в голове такую картинку:
Git - это key-value хранилище. Ключ - это SHA-1 хэш содержимого. Значение - само содержимое.
Любой объект, который попадает в Git, сначала пропускается через SHA-1 (40-символьный шестнадцатеричный хэш). Этот хэш и есть имя объекта. По нему - и только по нему - объект потом достают.
Никакой централизованной таблицы «список файлов с их версиями» в Git нет. Есть набор объектов с хэш-именами, и есть несколько указателей, которые знают, какой хэш считать «вершиной» истории.
Объектов всего четыре типа:
| Тип | Что хранит |
|---|---|
| blob | содержимое одного файла |
| tree | список файлов и папок в одной директории |
| commit | снимок (через tree) + метаданные |
| tag | именованный указатель на коммит с подписью |
Всё. Это вся модель данных Git. Дальше - детали.
3.2 Что лежит в .git/
После git init в директории появляется .git/. Посмотрим, что
внутри сразу после первого коммита:
ls .git/
# HEAD config description hooks/
# info/ objects/ refs/
Назначение каждого пункта:
- HEAD - указывает на текущую ветку. Файл из одной строки.
- config - локальная конфигурация репозитория.
- description - описание репозитория, используется только в
GitWeb. Можно игнорировать.
- hooks/ - скрипты, которые запускаются на разных событиях (см. главу 14).
- info/ - дополнительные настройки, в частности
info/exclude- локальный аналог.gitignore. - objects/ - хранилище всех объектов Git. Самое важное.
- refs/ - указатели на коммиты: ветки, теги, удалённые ветки.
Заглянем в HEAD:
cat .git/HEAD
# ref: refs/heads/main
Это указатель «текущая ветка - это файл refs/heads/main». А что
в нём:
cat .git/refs/heads/main
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Это SHA первого коммита. То есть «текущая ветка main находится на коммите с таким хэшем».
То есть ветка в Git - это просто файл с одним хэшем внутри. Не структура, не дерево, не граф - один файл, в нём 40 символов. Передвинуть ветку - значит изменить эти 40 символов.
Эта простота - фундамент того, почему ветки в Git дёшевы. См. branch.
3.3 Blob - самый простой объект
Blob (binary large object) - это объект, который хранит содержимое одного файла. Только содержимое. Без имени файла, без прав, без даты.
Имя файла, права, дата - лежат не в blob, а в tree. Blob знает только «вот байты».
Создать blob можно вручную, через низкоуровневую команду git hash-object:
echo "Hello, Git" | git hash-object --stdin -w
# 8d0e41234f24b6da002d962a26c2495ea16a425f
Что произошло:
- На вход подана строка
Hello, Git\n. - Git вычислил SHA-1 от строки
blob 11\0Hello, Git\n, где11
- длина содержимого в байтах,
\0- нулевой байт-разделитель.
- Получившийся хэш -
8d0e41.... - Флаг
-w(write) - сохрани этот blob в.git/objects/.
Заглянем, что появилось:
ls .git/objects/8d/
# 0e41234f24b6da002d962a26c2495ea16a425f
Git раскладывает объекты по подкаталогам: первые два символа SHA
- имя подкаталога, остальные 38 - имя файла. Это сделано для производительности файловой системы. Каталог с сотней тысяч файлов работает медленнее, чем 256 каталогов по тысяче файлов.
Сам файл - это zlib-сжатое содержимое. Чтобы достать содержимое
нормально - git cat-file:
git cat-file -p 8d0e41
# Hello, Git
-p - это «pretty print». git cat-file -t 8d0e41 - покажет тип
объекта (blob).
3.3.1 Копнуть глубже: SHA-1, коллизии, SHA-256
SHA-1 в 2017 году был официально сломан - Google показала пример двух разных файлов с одинаковым SHA-1. С точки зрения криптографии это значит, что SHA-1 нельзя использовать там, где важна защита от подделки.
Для Git это менее критично, чем кажется. Git использует хэш для
адресации, а не для подписи. Кроме того, в Git сначала идёт
префикс blob 11\0, что усложняет атаку: нужно подобрать
коллизию не для произвольных байтов, а для байтов с известным
префиксом и длиной.
Тем не менее в 2018 году началась миграция на SHA-256. Сейчас
(2026) Git поддерживает оба хэша, но SHA-256 включается только
если репозиторий создавался специально с флагом
--object-format=sha256. По умолчанию по-прежнему SHA-1. Полный
переход растянут на годы - слишком много инфраструктуры завязано
на 40-символьные хэши.
Все примеры в этой книге - на SHA-1. На SHA-256 идея та же, отличается только длина хэша (64 символа вместо 40).
3.4 Tree - снимок директории
Tree (дерево) хранит список того, что лежит в одной директории. Каждая запись - это:
- права доступа (
100644для обычного файла,100755для исполняемого,40000для поддиректории,120000для симлинка) - тип объекта (
blobилиtree) - SHA того объекта
- имя файла или папки
Tree - это связующее звено между «байты файла» (blob) и «имя файла, в которой лежат эти байты».
Посмотрим tree последнего коммита из лабы главы 2:
git cat-file -p HEAD^{tree}# 100644 blob 5f7e9c12... README.md
# 100644 blob 8a3f2e91... index.html
# 100644 blob b1d4a7e0... style.css
Три blob-а, каждый со своим SHA, у каждого - имя. Это и есть состояние рабочего дерева в момент коммита.
Если в проекте есть подкаталоги - в дереве встретятся записи типа
tree:
100644 blob 5f7e9c12... README.md
100644 blob 8a3f2e91... index.html
40000 tree e2b5a91f... images
100644 blob b1d4a7e0... style.css
tree e2b5a91f... - это SHA другого tree-объекта, который
описывает содержимое директории images/. Получается рекурсивная
структура: дерево директорий - это дерево tree-объектов.
коммит
└── tree (корень проекта)
├── blob README.md
├── blob index.html
├── tree images/
│ ├── blob logo.png
│ └── blob banner.jpg
└── blob style.css
Это в точности то же самое, что обычная файловая система. Только
вместо inode'ов - SHA, и каждый «inode» хранится в
.git/objects/.
См. tree.
3.5 Commit - снимок плюс метаданные
Commit - это самый «лицо проекта» объект. Когда говорят «коммит» - имеют в виду этот объект. Он хранит:
- SHA tree-объекта - снимок всех файлов проекта в этот момент
- SHA родительского коммита - какой коммит был до этого (для merge-коммитов родителей два или больше)
- автор: имя, email, дата
- коммиттер: имя, email, дата
- сообщение коммита
Посмотрим коммит вручную:
git cat-file -p HEAD
# tree 7e3f9a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f
# parent a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# author Имя Фамилия <email@example.com> 1716817381 +0300
# committer Имя Фамилия <email@example.com> 1716817381 +0300
#
# README с описанием проекта
Что важно понимать про этот объект:
Коммит не хранит изменения. Он хранит полный снимок (tree). Изменения - это разница между tree этого коммита и tree родителя, которую Git вычисляет на лету.
SHA коммита включает SHA родителя. Значит, если в любом коммите в цепочке что-то изменилось - у всех коммитов после него поменяются SHA. Это и есть тот механизм, через который Git гарантирует целостность истории.
Коммиттер и автор - это разные люди. Автор - тот, кто
написал изменения. Коммиттер - тот, кто закоммитил их в
репозиторий. Чаще всего совпадают. Расходятся при cherry-pick
или применении патча от другого человека.
См. commit.
3.5.1 Подводный камень: дата автора и дата коммита
В коммите две даты: author date и committer date. Большинство
команд (git log, git show) показывают author date. Но git rebase, git cherry-pick, git commit --amend оставляют author
date старым, а committer date обновляют до текущего времени.
Это значит, что после rebase автор и время в git log могут
показывать прошлый месяц, а реально коммит появился в репозитории
сегодня. Это не баг - это правильное поведение. Просто стоит
знать.
Видно обе даты так: git log --pretty=fuller.
3.6 Tag - именованный коммит
Тег - это указатель на коммит, у которого есть имя. Используется для отметки релизов: «вот этот коммит - это версия 1.0».
В Git два вида тегов.
Lightweight tag - это просто файл в .git/refs/tags/ с SHA
коммита внутри. Никакого объекта tag не создаётся. По сути - то
же самое, что ветка, но не двигается с коммитами.
git tag v1.0
cat .git/refs/tags/v1.0
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
Annotated tag - полноценный объект в .git/objects/, с
автором, датой, сообщением, и опционально GPG-подписью.
git tag -a v1.0 -m "Первая стабильная версия"
git cat-file -p v1.0
# object a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# type commit
# tag v1.0
# tagger Имя Фамилия <email@example.com> 1716817500 +0300
#
# Первая стабильная версия
Для релизов лучше использовать annotated - у них есть автор, дата и сообщение, их можно подписать GPG-ключом. Lightweight подходят для временных меток. См. tag.
3.7 Как объекты связаны
Соберём всё вместе. После двух коммитов в репозитории получается такая структура:
.git/refs/heads/main ──→ commit B (SHA b2c3d4e...)
│
├─ parent: commit A
└─ tree: tree2
│
├─ blob: README.md
├─ blob: index.html
└─ blob: style.css
commit A (SHA a1b2c3d...)
│
├─ parent: (нет, root commit)
└─ tree: tree1
│
├─ blob: README.md
└─ blob: index.html
Что важно увидеть на этой картинке:
- Ветка
mainуказывает только на верхний коммит (B). Черезparent-цепочку из B вытягивается вся история.- Tree2 содержит уже три файла, tree1 - два. Между ними появился
style.css.
- Tree2 содержит уже три файла, tree1 - два. Между ними появился
- Если файл
README.mdне менялся между A и B, то tree1 и tree2 будут ссылаться на один и тот же blob.
Это и есть основа «дешёвой» работы Git. История не пишется заново. Новый коммит - это новый tree + новый commit-объект. Всё, что не изменилось - переиспользуется.
3.8 Как git add и git commit это всё делают
Теперь обратный путь: что именно происходит, когда даётся git add file.txt и потом git commit.
git add file.txt:
- Прочитать содержимое
file.txtиз рабочего дерева. - Создать blob (SHA + сжатое содержимое в
.git/objects/). - Записать в индекс (
.git/index) строку: путьfile.txt, права, SHA блоба.
Tree пока не создаётся. Tree существует только в индексе как in-memory структура.
git commit -m "...":
- Из индекса собрать tree-объект (
write-tree). - Создать commit-объект:
- tree - только что собранный
- parent - текущий коммит (HEAD), если он есть
- author/committer - из конфига
- сообщение - переданное
-m
- Передвинуть текущую ветку (
refs/heads/main) на новый коммит. - Передвинуть HEAD, если он указывал на эту ветку.
После этого git log пройдётся по родителям и покажет историю.
3.9 Как git status понимает, что изменилось
Эта же модель объясняет, как Git определяет, какие файлы изменены, без хранения никакого журнала.
git status сравнивает три вещи:
- HEAD-tree - снимок последнего коммита.
- Индекс - staging area.
- Рабочее дерево - реальные файлы на диске.
Сравнение тривиальное:
- Если SHA файла в индексе совпадает с SHA в HEAD-tree → файл не изменён в индексе.
- Если SHA файла на диске совпадает с SHA в индексе → файл не изменён в рабочем дереве.
- Если файла нет в индексе, но он есть на диске → untracked.
Это всё. Никакого журнала, никакого «watch на изменения». Git каждый раз честно считает хэши.
На больших репозиториях для скорости включается index extension
- кэш модификаций файлов по mtime, чтобы не пересчитывать хэш для каждого файла. Но это оптимизация, не модель.
3.10 Packfiles - почему .git/ не растёт линейно
После сотни коммитов в .git/objects/ накопится множество
файлов. Если оставить как есть - это будет неэффективно: место на
диске занимается больше, чем нужно (несжатые отдельно файлы), и
операции вроде git push отправляли бы каждый объект отдельно.
Раз в какое-то время - после git gc, при git push/fetch, или
автоматически - Git собирает накопленные объекты в один сжатый
файл - packfile. Лежит он в .git/objects/pack/:
ls .git/objects/pack/
# pack-abc123...idx pack-abc123...pack
Внутри packfile:
- Объекты хранятся уже не как самостоятельные файлы, а в виде записей.
- Между похожими объектами вычисляются дельты. Если есть две версии большого файла, отличающиеся на пару строк - в packfile хранится одна полная и одна дельта от первой.
- Всё это дополнительно сжимается zlib.
Это и есть та самая «дельта-оптимизация», которую упоминали в главе 1. Она работает на уровне хранилища, не на уровне модели. Логически - всё ещё четыре типа объектов. Физически - они могут быть как loose-объектами (отдельный файл), так и внутри packfile.
git cat-file -p <sha> работает одинаково независимо от того,
где объект. См. packfile.
3.10.1 Копнуть глубже: когда срабатывает gc
git gc (garbage collection) собирает loose-объекты в packfile,
удаляет недостижимые объекты, оптимизирует репозиторий.
Запускается автоматически, когда:
- При очередной команде записи накопилось много loose-объектов
(по умолчанию >7000).
- При
git fetch- после получения новых объектов.
- При
Принудительно - git gc или git gc --aggressive.
Важная деталь: gc уважает reflog. Даже «удалённые» коммиты
(например, после reset --hard) живут как минимум 30 дней, пока
reflog их видит. Это часть того, почему git reflog спасает
после катастрофических ошибок. Только после git gc --prune=now
и зачистки reflog объекты действительно пропадают.
3.11 Контентно-адресуемое хранилище - что это значит на практике
«Контентно-адресуемое» (content-addressable) - это термин, который сам по себе ничего не объясняет, но идея за ним важная. Объект адресуется не по имени, а по содержимому. Изменилось содержимое - изменилось имя. Совпало содержимое - совпало имя.
Что это даёт.
Дедупликация бесплатно. Если в репозитории два одинаковых файла (или один и тот же файл в десяти коммитах) - на диске лежит один blob.
Целостность бесплатно. Если кто-то изменил файл в
.git/objects/ напрямую (битым диском, злым умыслом, чем угодно)
- SHA, которым он назван, перестанет совпадать с его содержимым.
Git это заметит при первой же попытке прочитать. Команда
git fsckпроверяет всю целостность репозитория.
Дешёвое сравнение. Чтобы понять, отличаются ли два файла, не нужно их сравнивать байт за байтом. Достаточно сравнить SHA.
Невозможность переписать историю незаметно. Любое изменение в любом коммите в прошлом - это новый SHA для этого коммита и всех последующих. Это даёт криптографическую гарантию аудита.
Уроки в sandbox
lab-3.1. Создать коммит вручную, без git add и git commit
Цель - собрать коммит из плумбинг-команд. Это упражнение убирает
магию: после него git add и git commit перестают казаться
монолитными командами.
Создай новый репозиторий:
mkdir manual-repo && cd manual-repo && git init.Создай blob из строки «hello»:
echo "hello" | git hash-object --stdin -w. Запиши полученный SHA, напримерce013625030ba8dba906f756967f9e9ca394464a.Добавь blob в индекс под именем
hello.txt:git update-index --add --cacheinfo 100644 <blob-sha> hello.txt.Запиши индекс как tree:
git write-tree. Получишь SHA tree, например8e8e7c4a3b....Создай commit из этого tree:
echo "Первый коммит руками" | git commit-tree <tree-sha>. Получишь SHA коммита.Передвинь ветку main на этот коммит:
git update-ref refs/heads/main <commit-sha>.Проверь, что всё сработало:
git log --onelineиgit cat-file -p HEAD. Должен показать твой коммит со ссылкой на tree и blob.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Git - это key-value хранилище, где ключ - SHA-1 хэш содержимого.
- Объектов четыре типа: blob (содержимое файла), tree (директория), commit (снимок + метаданные), tag (именованный указатель).
- Все объекты лежат в .git/objects/, разложенные по подкаталогам по первым двум символам SHA.
- Ветка в Git - это файл с одним SHA внутри.
- Коммит хранит SHA родителя, поэтому SHA любого коммита включает в себя всю историю до него. Подделать прошлое незаметно - невозможно.
- git add создаёт blob и записывает в индекс. git commit собирает tree из индекса и создаёт commit-объект.
- Packfiles - это оптимизация хранения. Модель данных от этого не меняется.
Контрольные вопросы
В чём принципиальная разница между blob и tree? Может ли blob содержать ссылку на другой blob?
Показать ответ
Blob хранит только содержимое файла - анонимные байты, без имени и прав. Tree содержит список записей
(права, тип, SHA, имя), которые могут указывать на blob (файлы) или другие tree (поддиректории). Blob ссылок ни на что не содержит - это просто байты. Связь между именем файла и его содержимым делает tree.Если два файла в репозитории идентичны по содержимому, занимают ли они в .git/objects/ место дважды?
Показать ответ
Нет. Git хранит контент по SHA содержимого. Идентичное содержимое даёт идентичный SHA. На уровне tree файлы будут двумя записями с разными именами, но обе ссылаются на один blob.
Я закоммитил файл, потом случайно поменял старый коммит в прошлом через `rebase -i`. Что произойдёт с SHA коммитов, которые шли после этого?
Показать ответ
У них поменяются SHA. Каждый коммит хранит SHA родителя как часть себя; если родитель изменился - изменилось содержимое коммита, значит и его SHA. Это каскадно: меняется один коммит - меняются все его потомки. Поэтому
rebaseсоздаёт новую цепочку коммитов, а не модифицирует существующую.Что делает команда `git update-ref refs/heads/main <sha>`? Может ли она «сломать» репозиторий?
Показать ответ
Перезаписывает файл
.git/refs/heads/mainуказанным SHA. То есть перемещает ветку main на коммит с этим SHA. Самаupdate-refпроверяет, что объект существует в.git/objects/, и откажется писать SHA, которого нет, - выдастfatal: trying to write ref ... with nonexistent object. А вот «сломать» репо можно прямой записью в.git/refs/heads/main(или передвинуть ветку на SHA объекта неподходящего типа в обход проверок) - тогда команды, которые ожидают коммит, начнут падать. Reflog запомнит предыдущее значение, восстановить можно.В чём отличие lightweight tag от annotated tag? Когда какой выбирать?
Показать ответ
Lightweight - просто файл с SHA коммита, без метаданных. Annotated
- отдельный объект в .git/objects/ с автором, датой, сообщением, опционально GPG-подписью. Для релизов и любых публичных меток использовать annotated. Lightweight - для временных пометок «глянь сюда», личных. Annotated можно подписать; lightweight - нет.