linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
Intro
Lessons
Footer
linuxlab-УчебникиЦеныО платформеКонфиденциальность и куки
Copyright © 2026 LinuxLab. Все права защищены.
linuxlab.io
Учебники▾
  • Линукс и сети
    Файловая система, процессы, TCP/IP, BGP и OSPF
    →
  • Terraform и IaC
    HCL, state, plan/apply на sandbox LocalStack
    →
  • Git и GitHub
    Объектная модель, plumbing, ветвление, GitHub Actions
    →
Все учебники →
ЦеныО платформеВойтиСоздать аккаунт
/
  • Введение
  • Главы
  • How it works
  • Уроки
  • База знаний
  • Собеседование
Часть II — Внутренности Git

$ глава 3 · 60 минут

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

Эта глава - фундамент. Без неё всё остальное в Git работает на уровне «вроде запомнил команды». С ней - понятно, почему git rebase иногда переписывает коммиты, а иногда нет, почему cherry-pick работает, почему reflog спасает после reset --hard, и почему ветки в Git стоят почти ничего.

Большинство учебников ставят эту тему в конец или вырезают. Это ошибка. Объектная модель Git - самая простая часть Git. Сложными являются команды, которые с ней работают.

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/. Посмотрим, что внутри сразу после первого коммита:

bash
ls .git/
# HEAD            config          description     hooks/
# info/           objects/        refs/

Назначение каждого пункта:

  • HEAD - указывает на текущую ветку. Файл из одной строки.
  • config - локальная конфигурация репозитория.
  • description - описание репозитория, используется только в GitWeb. Можно игнорировать.
    • hooks/ - скрипты, которые запускаются на разных событиях (см. главу 14).
    • info/ - дополнительные настройки, в частности info/exclude - локальный аналог .gitignore.
    • objects/ - хранилище всех объектов Git. Самое важное.
    • refs/ - указатели на коммиты: ветки, теги, удалённые ветки.

Заглянем в HEAD:

bash
cat .git/HEAD
# ref: refs/heads/main

Это указатель «текущая ветка - это файл refs/heads/main». А что в нём:

bash
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:

bash
echo "Hello, Git" | git hash-object --stdin -w
# 8d0e41234f24b6da002d962a26c2495ea16a425f

Что произошло:

  1. На вход подана строка Hello, Git\n.
  2. Git вычислил SHA-1 от строки blob 11\0Hello, Git\n, где 11
  • длина содержимого в байтах, \0 - нулевой байт-разделитель.
  1. Получившийся хэш - 8d0e41....
  2. Флаг -w (write) - сохрани этот blob в .git/objects/.

Заглянем, что появилось:

bash
ls .git/objects/8d/
# 0e41234f24b6da002d962a26c2495ea16a425f

Git раскладывает объекты по подкаталогам: первые два символа SHA

  • имя подкаталога, остальные 38 - имя файла. Это сделано для производительности файловой системы. Каталог с сотней тысяч файлов работает медленнее, чем 256 каталогов по тысяче файлов.

Сам файл - это zlib-сжатое содержимое. Чтобы достать содержимое нормально - git cat-file:

bash
git cat-file -p 8d0e41
# Hello, Git

-p - это «pretty print». git cat-file -t 8d0e41 - покажет тип объекта (blob).

См. blob и sha1.

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:

bash
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, дата
  • сообщение коммита

Посмотрим коммит вручную:

bash
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 не создаётся. По сути - то же самое, что ветка, но не двигается с коммитами.

bash
git tag v1.0
cat .git/refs/tags/v1.0
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0

Annotated tag - полноценный объект в .git/objects/, с автором, датой, сообщением, и опционально GPG-подписью.

bash
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.
  • Если файл 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:

  1. Прочитать содержимое file.txt из рабочего дерева.
  2. Создать blob (SHA + сжатое содержимое в .git/objects/).
  3. Записать в индекс (.git/index) строку: путь file.txt, права, SHA блоба.

Tree пока не создаётся. Tree существует только в индексе как in-memory структура.

git commit -m "...":

  1. Из индекса собрать tree-объект (write-tree).
  2. Создать commit-объект:
  • tree - только что собранный
  • parent - текущий коммит (HEAD), если он есть
  • author/committer - из конфига
  • сообщение - переданное -m
  1. Передвинуть текущую ветку (refs/heads/main) на новый коммит.
  2. Передвинуть HEAD, если он указывал на эту ветку.

После этого git log пройдётся по родителям и покажет историю.

3.9 Как git status понимает, что изменилось

Эта же модель объясняет, как Git определяет, какие файлы изменены, без хранения никакого журнала.

git status сравнивает три вещи:

  1. HEAD-tree - снимок последнего коммита.
  2. Индекс - staging area.
  3. Рабочее дерево - реальные файлы на диске.

Сравнение тривиальное:

  • Если 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/:

bash
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 перестают казаться монолитными командами.

  1. Создай новый репозиторий: mkdir manual-repo && cd manual-repo && git init.

  2. Создай blob из строки «hello»: echo "hello" | git hash-object --stdin -w. Запиши полученный SHA, например ce013625030ba8dba906f756967f9e9ca394464a.

  3. Добавь blob в индекс под именем hello.txt: git update-index --add --cacheinfo 100644 <blob-sha> hello.txt.

  4. Запиши индекс как tree: git write-tree. Получишь SHA tree, например 8e8e7c4a3b....

  5. Создай commit из этого tree: echo "Первый коммит руками" | git commit-tree <tree-sha>. Получишь SHA коммита.

  6. Передвинь ветку main на этот коммит: git update-ref refs/heads/main <commit-sha>.

  7. Проверь, что всё сработало: 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 - это оптимизация хранения. Модель данных от этого не меняется.

Контрольные вопросы

  1. В чём принципиальная разница между blob и tree? Может ли blob содержать ссылку на другой blob?

    Показать ответ

    Blob хранит только содержимое файла - анонимные байты, без имени и прав. Tree содержит список записей (права, тип, SHA, имя), которые могут указывать на blob (файлы) или другие tree (поддиректории). Blob ссылок ни на что не содержит - это просто байты. Связь между именем файла и его содержимым делает tree.

  2. Если два файла в репозитории идентичны по содержимому, занимают ли они в .git/objects/ место дважды?

    Показать ответ

    Нет. Git хранит контент по SHA содержимого. Идентичное содержимое даёт идентичный SHA. На уровне tree файлы будут двумя записями с разными именами, но обе ссылаются на один blob.

  3. Я закоммитил файл, потом случайно поменял старый коммит в прошлом через `rebase -i`. Что произойдёт с SHA коммитов, которые шли после этого?

    Показать ответ

    У них поменяются SHA. Каждый коммит хранит SHA родителя как часть себя; если родитель изменился - изменилось содержимое коммита, значит и его SHA. Это каскадно: меняется один коммит - меняются все его потомки. Поэтому rebase создаёт новую цепочку коммитов, а не модифицирует существующую.

  4. Что делает команда `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 запомнит предыдущее значение, восстановить можно.

  5. В чём отличие lightweight tag от annotated tag? Когда какой выбирать?

    Показать ответ

    Lightweight - просто файл с SHA коммита, без метаданных. Annotated

    • отдельный объект в .git/objects/ с автором, датой, сообщением, опционально GPG-подписью. Для релизов и любых публичных меток использовать annotated. Lightweight - для временных пометок «глянь сюда», личных. Annotated можно подписать; lightweight - нет.
← Предыдущая02-first-repoСледующая →04-plumbing
Footer
linuxlab-
Copyright © 2026 LinuxLab. Все права защищены.
Учебники
Цены
О платформе
Конфиденциальность и куки