Git и командная работа Внутри Git: объекты, blob, tree, commit, ссылки и DAG истории
0%

Внутри Git: объекты, blob, tree, commit, ссылки и DAG истории

Внутри Git: объекты, blob, tree, commit, ссылки и DAG истории

Большинство людей учат Git как набор заклинаний: «чтобы отменить — вот это, чтобы влить — вот то». Это работает ровно до первой нештатной ситуации, после которой начинается паника и поиск рецепта в интернете, часто неподходящего. Причина не в том, что Git сложный, а в том, что порядок объяснения перевёрнут: сначала интерфейс, потом (никогда) модель.

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

Эта статья — про модель. Введение в системы контроля версий и базовый цикл работы есть в статье Git курса Development, карта трека — в обзоре. Здесь мы лезем внутрь .git и разбираем его по байтам.

Одна идея, из которой следует всё остальное

Git — это контентно-адресуемое хранилище (content-addressable storage) с графом поверх него.

Обычная файловая система адресует данные по имени: «дай мне /home/ada/README.md». Контентно-адресуемое хранилище адресует по содержимому: «дай мне объект ce013625…», и оно возвращает данные, чей хеш равен ce013625…. Имя не назначается — оно вычисляется. Отсюда три свойства, определяющие весь Git:

  1. Дедупликация бесплатна. Два одинаковых файла — один объект, независимо от того, где они лежат, в какой ветке и в каком коммите.
  2. Данные неизменяемы. Изменив содержимое, вы получаете другой адрес, то есть другой объект. Старый никуда не девается. «Изменить коммит» физически невозможно — можно только создать новый.
  3. Целостность проверяется тривиально. Пересчитал хеш, сравнил с адресом. Причём хеш верхнего объекта зависит от хешей всех нижних, поэтому подмена байта в древнем коммите меняет идентификаторы всей последующей истории.

Третье свойство означает, что граф объектов Git — это дерево Меркла (точнее, Merkle DAG), та же конструкция, что в блокчейнах, BitTorrent, IPFS и дедуплицирующих бэкапах. Если вы читали про хеш-функции в хеш-таблицах или про криптографические хеши в прикладной криптографии — здесь та же математика, применённая к файлам.

$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

Это не «хеш файла README.md» и даже не «хеш строки hello». Это SHA-1 от последовательности байт blob 6\0hello\n: тип объекта, пробел, длина содержимого в байтах, нулевой байт, содержимое. Заголовок обязателен и важен — он делает адрес зависящим от типа, поэтому blob и commit с одинаковым телом получат разные идентификаторы.

Анатомия loose-объекта Git

Теперь то же в настоящем репозитории:

$ git init -q -b main && printf 'hello\n' > README.md && git add README.md
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
$ git cat-file -t ce013625 && git cat-file -s ce013625 && git cat-file -p ce013625
blob
6
hello

Мы ещё не делали коммит, а объект уже на диске. git add — это не «пометить файл»; это «записать содержимое в базу объектов и запомнить адрес в индексе». А вот тот же файл без помощи Git:

import zlib, hashlib
raw = zlib.decompress(open(".git/objects/ce/013625030ba8dba906f756967f9e9ca394464a", "rb").read())
print(repr(raw))                      # b'blob 6\x00hello\n'
print(hashlib.sha1(raw).hexdigest())  # ce013625030ba8dba906f756967f9e9ca394464a

Важная деталь: хеш считается до сжатия. Поэтому смена уровня компрессии, переупаковка в packfile или перенос репозитория между машинами не меняют идентификаторы объектов.

Про SHA-1 и переход на SHA-256

SHA-1 сломан для коллизий: в феврале 2017 команда Google и CWI опубликовала SHAttered — два разных PDF с одинаковым SHA-1. Git ответил двумя вещами. Во-первых, с версии 2.13 в него встроен детектор коллизий sha1dc: он считает обычный SHA-1, попутно ища следы дифференциальной атаки, и падает с ошибкой, если находит; цена — 15–30% скорости хеширования. Во-вторых, придуман формат репозитория с SHA-256 (git init --object-format=sha256, проверяется через git rev-parse --show-object-format). Он работает, но остаётся изолированным: полноценной совместимости SHA-1 ↔ SHA-256 пока нет, большинство хостингов такие репозитории не принимают. План перехода описан в hash-function-transition — полезно прочитать хотя бы ради понимания, насколько дорого менять фундамент у распределённой системы.

И отдельно: хеш не защищает от подделки, он защищает от порчи. Тот, кто может переписать историю и заставить вас её принять, пересчитает все хеши. От подделки защищают подписи — про них в статье Хуки и автоматизация и в материале про безопасность цепочки поставок.

Четыре типа объектов

Вся база данных Git состоит из объектов ровно четырёх типов.

blob — содержимое файла и ничего больше

Blob не знает своего имени, прав доступа и каталога. Это чистая последовательность байт. Один blob может быть одновременно README.md в одной ветке и docs/intro.txt в другой.

$ printf 'hello\n' > a.txt && printf 'hello\n' > b.txt && git hash-object a.txt b.txt
ce013625030ba8dba906f756967f9e9ca394464a
ce013625030ba8dba906f756967f9e9ca394464a

Два файла — один объект. Скопируйте гигабайтный файл в двадцать мест, и репозиторий вырастет на один гигабайт, а не на двадцать.

tree — каталог

Дерево — отсортированный список записей «режим, имя, идентификатор объекта». Именно здесь появляются имена файлов и права.

$ git cat-file -p HEAD^{tree}
100644 blob 94954ab…	README.md
100644 blob 975fbec…	src-extra.txt
100644 blob 587be6b…	src.txt
040000 tree b9270df…	src

git cat-file -p печатает дерево человекочитаемо, но на диске это бинарь вида <режим> SP <имя> NUL <20 байт SHA>, записи подряд без разделителей. Отсюда несколько неочевидных фактов:

  • Режим каталога хранится как 40000, без ведущего нуляgit cat-file дорисовывает его для выравнивания. Свой парсер, слепо ждущий шесть символов, поймает баг.
  • Список режимов крошечный и это не POSIX-права. Git знает ровно 100644 (обычный файл), 100755 (исполняемый), 120000 (символическая ссылка), 040000 (дерево), 160000 (gitlink — указатель на коммит в подмодуле). Никакого владельца, группы и chmod 640: Git хранит один бит прав — исполняемый или нет.
  • Сортировка по имени, но каталоги сортируются так, будто у них есть завершающий /. В примере выше src-extra.txt < src.txt < src/, потому что сравниваются байты - (0x2D) < . (0x2E) < / (0x2F). Порядок обязателен: перепутаете — получите другой хеш дерева при том же содержимом.

Символическая ссылка — это blob, содержимым которого является путь назначения: git cat-file -p от записи с режимом 120000 напечатает README.md, а не содержимое файла.

commit — снимок плюс контекст

$ git cat-file -p HEAD
tree 740b896c87dd2d82d7e42ab7920ccaa0114562ce
parent 30bd639d1e3e87b7a9cb3513615b4e8466ef04ff
parent 53cb3ad83bf8660128a31c47d98cc9558a1cdeea
author Ada Lovelace <ada@example.com> 1784199600 +0300
committer Ada Lovelace <ada@example.com> 1784199600 +0300

Влить feature-auth
  • tree — корневое дерево. Один коммит = один полный снимок проекта. Не diff, не патч, не «изменения» — полный снимок. Это первое, что нужно перестроить в голове: Git не хранит разницу между версиями, он хранит версии, а diff вычисляет на лету по запросу.
  • parent — от нуля до N родителей. Ноль у корневого коммита, один у обычного, два у слияния, больше двух у «осьминога» (git merge a b c), который на практике встречается в основном в ядре Linux.
  • author и committer — две разные пары «человек + время». Автор написал изменение, коммиттер положил его в историю. Обычно совпадают; расходятся после rebase, cherry-pick, git am из письма. Отсюда практический вывод: git log --author и «кто это выкатил» — разные вопросы. Время хранится как Unix-таймстамп плюс смещение зоны отдельным полем, чтобы можно было показать «как было на часах у автора».
  • Опционально бывают заголовки gpgsig (подпись), encoding, mergetag.

Из формата немедленно следует важное: в коммите нет ни списка изменённых файлов, ни информации о переименованиях. «Файл переименовали» — вывод, который Git делает во время git log или git diff, сравнивая два дерева и находя удалённый и добавленный файл с похожим содержимым (эвристика similarity, порог настраивается через -M50%). Переименование с массивной правкой внутри Git «теряет»: похожесть падает ниже порога.

Второе следствие: время в коммите не определяет порядок истории. Порядок задаёт только топология графа. Таймстамп можно поставить любой, часы на машинах расходятся, rebase сохраняет старые даты авторства. Сортируя историю по дате, вы сортируете по ненадёжному полю — та же проблема, что с часами в распределённых системах.

tag — именованный указатель как объект

Лёгкий тег (git tag v1.0) — просто файл со ссылкой, объект не создаётся. Аннотированный (git tag -a) создаёт настоящий объект:

$ git tag -a v0.1 -m "Первый релиз" && git cat-file -p v0.1
object 345fc93fef34f540e7a4c7c2963cd322bfea3c35
type commit
tag v0.1
tagger Ada Lovelace <ada@example.com> 1785332798 +0000

Тег-объект — единственное место, где хранится «кто и когда пометил релиз», и единственный тип тега, который можно подписать. Для релизов всегда используйте аннотированные.

Собираем коммит руками

Лучшая проверка того, что модель понята, — построить коммит без git commit, только plumbing-командами. Заодно видно, что рабочий каталог коммиту вообще не нужен.

$ B=$(printf 'plumbing\n' | git hash-object -w --stdin)     # 1. записать blob в базу
$ T=$(printf '100644 blob %s\tnote.txt\n' $B | git mktree)  # 2. собрать дерево
$ C=$(git commit-tree $T -m "Коммит без рабочего каталога") # 3. создать коммит
$ git update-ref refs/heads/manual $C                       # 4. вот теперь он «в истории»

Обычный git commit делает ровно это, только берёт дерево из индекса:

Три вывода, снимающие половину вопросов: коммитится индекс, а не рабочий каталог (отредактировали файл после git add — в коммит уйдёт версия на момент add); git add уже записал данные в базу (файл, добавленный в индекс и затёртый на диске, восстановим); создание коммита не трогает рабочий каталог вообще — меняются только .git/objects и один файл ссылки.

Неизменяемость и структурное разделение

Раз объекты неизменяемы, каждый коммит ссылается на полный снимок. Не разорвёт ли это диск? Нет — снимок собран из ссылок, и неизменившиеся части переиспользуются как есть. Эксперимент: репозиторий из трёх файлов, правим один комментарий в src/api/handler.go.

$ git count-objects -v | head -1                 # count: 9
$ printf 'package api\n\n// Handler обрабатывает запрос.\n' > src/api/handler.go
$ git add -A && git commit -qm "Комментарий к Handler"
$ git count-objects -v | head -1                 # count: 14

Ровно +5: новый blob handler.go, новые деревья src/api/, src/ и корневое, плюс сам коммит. Blob’ы README.md и src/main.go не тронуты — на них ссылаются оба снимка.

Структурное разделение объектов между коммитами

Правило: правка файла на глубине d создаёт d + 1 новых деревьев, один blob и один коммит. Стоимость коммита пропорциональна не размеру проекта, а глубине изменённых путей: O(d) новых деревьев на изменённый файл, а не O(числа файлов в репозитории). Это в точности персистентная структура данных со структурным разделением — та же техника, что в неизменяемых коллекциях функциональных языков, с подробным разбором стоимости в статье про персистентные структуры данных.

Практическое следствие ощущается каждый день: сравнение двух версий проекта — это сравнение хешей деревьев. Если у двух коммитов хеш дерева src/ совпал, там гарантированно ничего не изменилось и спускаться внутрь не нужно. Поэтому git diff между далёкими коммитами огромного репозитория отрабатывает мгновенно:

$ git diff-tree -r HEAD~1 HEAD
:100644 100644 778f64e… f2065bd… M	src/api/handler.go

Одна строка вместо обхода тысяч файлов — потому что все остальные поддеревья совпали по хешу.

Ссылки: почему ветка стоит 41 байт

Объекты — это данные. Ссылки — то, что делает их доступными, и устроены они до смешного просто.

$ cat .git/refs/heads/main && wc -c .git/refs/heads/main && cat .git/HEAD
345fc93fef34f540e7a4c7c2963cd322bfea3c35
41 .git/refs/heads/main
ref: refs/heads/main

Ветка — это файл с сорока символами хеша и переводом строки. Ни списка коммитов, ни метаданных, ни истории. Поэтому в Git ветки дешёвые: создать = записать 41 байт, удалить = удалить файл, переключиться = переписать HEAD и выложить различающиеся файлы.

HEAD — ссылка на ссылку. Когда в ней ref: …, вы «на ветке», и коммит двигает эту ветку. Когда прямой хеш — это detached HEAD: коммиты создаются, но ни одна ветка на них не указывает, и после переключения их держит только reflog (git status -sb покажет ## HEAD (no branch)).

Путь в .git Что там
objects/ база объектов: xx/yyyy… — loose, pack/ — упакованные плюс .idx
refs/heads, refs/tags, refs/remotes локальные ветки, теги, снимок того, что видели у origin
HEAD, ORIG_HEAD где мы сейчас и куда указывал HEAD до опасной операции
packed-refs ссылки, свёрнутые в один файл ради скорости
index индекс, он же staging area
logs/HEAD, logs/refs/… reflog для HEAD и для каждой ветки отдельно
config, hooks/ настройки этого репозитория и скрипты на события

Три вещи про ссылки, важные на практике. packed-refs: когда веток и тегов тысячи, Git сворачивает их в один файл, поэтому cat .git/refs/heads/main может сказать «нет такого файла», хотя git rev-parse main работает; универсальный способ читать ссылки — git show-ref и git for-each-ref, а не cat. refs/remotes/origin/main — это не ветка на сервере, а ваш локальный снимок того, где она была во время последнего git fetch; сам он не обновляется. Новый бэкенд reftable (экспериментально с Git 2.45) заменяет файлы бинарным форматом ради репозиториев с сотнями тысяч ссылок, но модель не меняет: ссылка по-прежнему «имя → идентификатор объекта».

Синтаксис обращения к объектам (gitrevisions) — по сути язык запросов к графу:

$ git rev-parse HEAD^{tree}        # дерево коммита
$ git rev-parse HEAD:src/main.go   # blob по пути внутри коммита
$ git rev-parse HEAD@{1}           # где HEAD был на прошлом шаге (reflog)
$ git rev-parse main@{yesterday}   # где ветка была вчера
$ git rev-parse :/README           # последний коммит, чьё сообщение содержит README
$ git rev-parse HEAD~3 HEAD^2      # третий предок по первому родителю; второй родитель merge

Разницу ~ и ^ стоит запомнить один раз: ~n — идти на n шагов назад, всегда по первому родителю; ^n — выбрать n-го родителя этого коммита. Для коммита с одним родителем они совпадают, для merge — нет.

Индекс: третье дерево, о котором все забывают

.git/index многие считают «списком добавленных файлов». На деле это плоский, полностью развёрнутый снимок будущего дерева плюс кеш метаданных файловой системы.

$ git ls-files -s
100644 ce013625030ba8dba906f756967f9e9ca394464a 0	README.md
100644 778f64ec17cd4fd767e18d43231361d3aff70366 0	src/api/handler.go
100644 38dd16da61accb1a8de6ac8709d2e65ef4a51a4a 0	src/main.go

Ноль в третьей колонке — stage. В нормальном состоянии он нулевой, а во время конфликта слияния один путь появляется в индексе трижды: stage 1 — общий предок, stage 2 — «наша» версия, stage 3 — «их». Именно из этих записей git checkout --ours/--theirs и mergetool берут данные; подробности — в статье Конфликты слияния.

Вторая роль индекса — stat-кеш:

$ git ls-files --debug | head -4
README.md
  ctime: 1785332810:452036723    mtime: 1785332810:452036723
  dev: 64513   ino: 6211983      uid: 1007   gid: 1009
  size: 12     flags: 0

Благодаря сохранённым ctime, mtime, иноду и размеру git status в репозитории на сто тысяч файлов не хеширует их все: он делает lstat и сравнивает метаданные, а содержимое читает только у подозрительных. Отсюда же классические патологии производительности — git status тормозит после touch по всему дереву, на сетевых ФС и в монорепозиториях, где помогают core.untrackedCache, core.fsmonitor и sparse-index (см. Большие репозитории и монорепо).

Ментальная модель, закрывающая большую часть ежедневных вопросов: у вас всегда три дереваHEAD (что в последнем коммите), индекс (что уйдёт в следующий) и рабочий каталог (что вы видите). Все варианты git reset — про то, какие из трёх подтянуть: --soft двигает только HEAD, --mixed (по умолчанию) — HEAD и индекс, --hard — все три. Разбор с примерами — в статье Ежедневная работа.

DAG: история как граф, а не как список

Коммиты ссылаются на родителей, значит образуют граф. Рёбра направлены назад во времени (ребёнок знает родителя, родитель о ребёнке не знает), циклов быть не может (нельзя сослаться на ещё не вычисленный хеш). Получается направленный ациклический граф, DAG.

Тот же граф глазами Git:

$ git log --graph --oneline --all | head -6
*   345fc93 Влить feature-auth
|\
| * 53cb3ad Пакет auth
* | 30bd639 Дополнить README
|/
* a9e7f6a Комментарий к Handler

Здесь важно чётко сказать вещь, которую обычно проговаривают невнятно:

Ветка не «содержит» коммиты. Ветка — указатель на один коммит. Фраза «коммит в ветке main» означает ровно «коммит достижим из refs/heads/main по рёбрам parent». Один и тот же коммит может быть достижим из десяти веток одновременно — он от этого не копируется.

Отсюда весь язык запросов к истории:

$ git merge-base main feature-auth              # ближайший общий предок (LCA в DAG)
a9e7f6aad7043e69a9f5ee96e26f73b6d0199191
$ git rev-list --count main..feature-auth       # достижимо из feature-auth, но не из main
1
$ git log --oneline --left-right main...feature-auth   # симметричная разница
< 30bd639 Дополнить README
> 53cb3ad Пакет auth

A..B читается как «множество B минус множество A по достижимости» — тот же обход графа, что BFS/DFS из статьи про обход графов, только вершины это коммиты. Именно A..B стоит за вопросом «что попадёт в мой Pull Request» (см. Как создать хороший Pull Request), за git log origin/main..HEAD и за счётчиком «ahead 3, behind 5» в git status.

Порядок родителей — не деталь. У merge-коммита родители упорядочены: первый — та ветка, на которой вы стояли, второй — та, которую вливали. Из этого растёт --first-parent:

$ git log --oneline --first-parent
345fc93 Влить feature-auth
30bd639 Дополнить README
a9e7f6a Комментарий к Handler

Коммит 53cb3ad Пакет auth исчез: он не на первой линии. История main как последовательность влитых работ, без внутренней кухни веток. На этом же свойстве держатся git bisect --first-parent и генерация changelog по релизной ветке. И ровно поэтому случайный git pull с merge в обратную сторону так раздражает: он делает вашу ветку первым родителем, и линия main перестаёт быть линией main.

Свой обходчик графа за 40 строк

Проверим полноту модели — напишем мини-git log, читающий объекты напрямую, без библиотек.

walk(tips):
    seen ← множество из tips;  queue ← очередь из tips
    пока queue не пуста:
        sha ← извлечь из queue
        объект ← распаковать zlib(.git/objects/sha[:2]/sha[2:])
        headers, message ← разобрать текст коммита
        вывести sha и первую строку message
        для каждого parent в headers[parent]:
            если parent ∉ seen: добавить в seen и в queue
import zlib
from pathlib import Path


def read_object(repo: Path, sha: str) -> tuple[str, bytes]:
    """Читает loose-объект: путь = objects/<2 символа>/<38 символов>."""
    raw = zlib.decompress((repo / ".git" / "objects" / sha[:2] / sha[2:]).read_bytes())
    head, _, body = raw.partition(b"\0")
    kind, size = head.decode().split(" ")
    assert int(size) == len(body), "битый объект: длина не совпала"
    return kind, body


def parse_commit(body: bytes) -> tuple[dict[str, list[str]], str]:
    """Коммит — текст: заголовки, пустая строка, сообщение."""
    head, _, message = body.partition(b"\n\n")
    headers: dict[str, list[str]] = {}
    for line in head.decode().split("\n"):
        key, _, value = line.partition(" ")
        headers.setdefault(key, []).append(value)
    return headers, message.decode()


def walk(repo: Path, tips: list[str]) -> list[tuple[str, str]]:
    """Обход DAG в ширину. Время O(V + E), память O(V)."""
    seen, queue, order = set(tips), list(tips), []
    while queue:
        sha = queue.pop(0)
        kind, body = read_object(repo, sha)
        assert kind == "commit"
        headers, message = parse_commit(body)
        order.append((sha[:7], message.strip().split("\n")[0]))
        for parent in headers.get("parent", []):
            if parent not in seen:
                seen.add(parent)
                queue.append(parent)
    return order


repo = Path(".")
head = (repo / ".git" / "refs" / "heads" / "main").read_text().strip()
print(walk(repo, [head]))   # [('e2595eb', 'Второй коммит'), ('4faa0d3', 'Первый коммит')]

Сорок строк — и мы прочитали репозиторий без единой команды Git. Сложность обхода — O(V + E) по времени и O(V) по памяти, где V — число коммитов, E — число рёбер parent (в среднем чуть больше V, потому что merge-коммитов немного). Поэтому git log в репозитории с миллионом коммитов не мгновенный, а git log -20 мгновенный: обход ленивый и останавливается, набрав нужное. Чтобы обход не деградировал на гигантских репозиториях, у Git есть commit-graph — бинарный кеш родителей и «номеров поколений» (git commit-graph write --reachable, автоматически при fetch.writeCommitGraph=true). Он ускоряет merge-base, rev-list --count и git log --graph на порядок, позволяя отсекать ветви обхода, не распаковывая объекты.

Достижимость и жизненный цикл объекта

Раз история — это достижимость, то и «удаление» в Git — про достижимость.

Ключевая мысль: ни одна обычная команда Git не удаляет данные сразу. git reset --hard, git branch -D, неудачный rebase, git commit --amend лишь двигают указатели; объекты остаются в базе до сборки мусора. Демонстрация — делаем коммит в ветке и удаляем ветку:

$ git switch -c experiment
$ printf 'X\n' > lost.txt && git add -A && git commit -qm "Очень важная работа"
$ git switch -q main && git branch -D experiment
Deleted branch experiment (was 8073f35).

Работа «потеряна», но коммит на месте, и найти его можно двумя путями. Первый — reflog, журнал перемещений HEAD и веток:

$ git reflog
345fc93 HEAD@{0}: checkout: moving from experiment to main
8073f35 HEAD@{1}: commit: Очень важная работа
345fc93 HEAD@{2}: checkout: moving from main to experiment
345fc93 HEAD@{3}: merge feature-auth: Merge made by the 'ort' strategy.

Второй — проверка целостности, показывающая объекты без входящих ссылок. Восстановление после этого — просто создание ссылки на найденный объект:

$ git fsck --lost-found
dangling commit 8073f3559285d15ddfea4589ccad3cc1b7258019
$ git branch experiment-restored 8073f35 && git log --oneline -1 experiment-restored
8073f35 Очень важная работа

Reflog локальный, поштучный на каждую ветку (.git/logs/refs/heads/main), не передаётся при push/clone и живёт по умолчанию 90 дней для достижимых записей и 30 для недостижимых. Формат — обычный текст: «старый sha, новый sha, кто, когда, что за операция». Практическое правило: в 95% случаев «я всё сломал» лечится git reflog плюс git reset --hard HEAD@{n}; глубокий разбор сценариев — в статье Восстановление.

Сборка мусора запускается вручную (git gc) и автоматически (gc.auto, по умолчанию при накоплении 6700 loose-объектов). git gc выкидывает недостижимое старше двух недель, git gc --prune=now — всё сразу. Поэтому «данные не пропадают» — не гарантия, а окно в две недели: ищите потерянное до сборки мусора и никогда не запускайте --prune=now в панике.

Packfiles: почему репозиторий не весит терабайт

Loose-объекты удобны, но неэффективны: каждый объект — отдельный файл, сжатый независимо, и сто версий файла на 1 МБ дают сто сжатых мегабайт. Поэтому Git периодически переупаковывает базу в packfile.

$ git count-objects -v | head -2 && git gc -q && git count-objects -v | head -3
count: 26        # loose-объектов до упаковки
in-pack: 0
count: 0         # после git gc
in-pack: 30
packs: 1

Внутри packfile объекты хранятся либо целиком, либо как дельта от другого объекта. Обратите внимание на инверсию, которую все путают: Git не хранит историю в виде дельт, но сжимает её дельтами. Разница принципиальная — дельты здесь чисто транспортно-компрессионный приём, кандидаты выбираются эвристикой по похожести и размеру, а не по истории: дельта может ссылаться на объект из другой ветки, и обычно новая версия хранится как дельта от старой.

$ git verify-pack -v .git/objects/pack/pack-*.idx | tail -2
non delta: 26 objects
chain length = 1: 1 object

Полезные настройки и факты: pack.window (10) и pack.depth (50) задают, сколько кандидатов Git рассматривает для дельты и какой длины цепочку допускает (длиннее цепочка — меньше размер, дольше чтение); core.bigFileThreshold (512 МБ) — файлы крупнее хранятся без дельт и без zlib, потому что у видео и архивов дельты всё равно не работают, а CPU жалко; .idx — отсортированный индекс packfile со смещениями, поиск бинарный за O(log n), а при множестве пакетов помогает git multi-pack-index write. Формат подробно описан в gitformat-pack — он же передаётся по сети при push и fetch.

Отсюда понятно, почему бинарники ломают репозиторий: дельты между двумя версиями JPEG или ZIP практически невозможны, сжатые данные меняются целиком от малейшей правки. Каждая версия ложится в историю полным весом и остаётся навсегда, потому что объекты неизменяемы. Отсюда Git LFS и git filter-repo — разбор в статьях Большие репозитории и монорепо и Переписывание истории.

Как модель отвечает на вопросы про команды

Ценность модели в том, что она превращает запоминание в вывод.

Команда Создаёт объекты? Двигает указатели? Трогает рабочий каталог?
git add да, blob’ы нет нет
git commit да, деревья и коммит да, ветку и HEAD нет
git branch X нет создаёт новую ссылку нет
git switch X нет HEAD да
git reset --soft / --mixed / --hard X нет HEAD и ветку; --mixed ещё индекс только --hard
git merge (fast-forward / обычный) только обычный: коммит с 2 родителями ветку да
git rebase да, новые коммиты ветку да
git cherry-pick, git revert да, новый коммит ветку да
git tag -a да, объект тега создаёт ссылку нет
git fetch да, чужие объекты refs/remotes/* нет
git push да, у собеседника ссылку на сервере нет

Из таблицы сразу выводится то, что обычно заучивают. Почему cherry-pick даёт другой хеш? Потому что у нового коммита другой родитель и другое время коммиттера, а хеш считается от всего текста коммита; одинаковый diff не делает коммит тем же объектом. Почему после rebase «пропадают» комментарии ревью? Потому что старых коммитов в ветке больше нет — созданы новые объекты, а комментарии привязаны к старым идентификаторам. Почему git push --force опасен, но не «удаляет» данные? Он переставляет ссылку на сервере; старые коммиты живут в базе сервера до его gc, зато локальные refs/remotes/origin/* у коллег теперь врут. Безопаснее --force-with-lease, который откажется двигать ссылку, если она изменилась с вашего последнего fetch. Почему git checkout мгновенный? Потому что переключение сравнивает деревья по хешам и трогает только различающиеся пути.

Merge и rebase: честно

Здесь принято занимать позицию, поэтому скажем как есть, глядя на объекты.

Merge создаёт один новый коммит с двумя родителями. Существующие коммиты не трогаются вообще, ни один объект не переписывается. Топология сохраняет факт: «эти работы шли параллельно и были объединены тогда-то». Цена — граф ветвится, git log без --first-parent показывает перемешанную ленту, git bisect обходит больше вершин.

Rebase не «переносит коммиты» — он создаёт новые коммиты с другими родителями. Старые объекты остаются в базе (их держит reflog до gc), но из ветки исчезают. Топология становится линейной, A..B читается тривиально, откат одного изменения очевиден. Цена — идентификаторы меняются, и любой, кто уже забрал старые коммиты, окажется в расхождении.

Главное: обе операции одинаково «настоящие» для Git. Модель данных не имеет предпочтений — merge создаёт коммит с двумя родителями, rebase создаёт цепочку коммитов с одним. Разница проявляется не в Git, а в процессах вокруг него: как команда делает ревью, как считает релизные заметки, насколько ей важна воспроизводимость расследований инцидентов, насколько тяжелы конфликты, кто и как часто синхронизирует ветки. Поэтому спор «rebase или merge» почти никогда не решается аргументами про Git — он решается аргументами про команду. Технический разбор обеих операций и критерии выбора — в статье Merge и rebase, варианты процессов вокруг них — в Стратегиях ветвления.

Единственное жёсткое правило, которое действительно следует из модели: не переписывайте историю, которую уже забрали другие. Не потому, что «rebase плохой», а потому что чужие локальные ссылки указывают на объекты, которых больше нет в вашей ветке, и синхронизация превращается в ручную работу.

Типичные заблуждения

  • «Git хранит diff между версиями». Нет: хранит снимки, дельты применяет при упаковке, diff вычисляет по запросу. Проверяется за секунду через git cat-file -p HEAD^{tree}.
  • «Переименование сохраняется в истории». Нет: хранятся два дерева, в одном файл под старым именем, в другом под новым. Переименование — вывод эвристики similarity во время диффа, поэтому git log --follow иногда «теряет» файл.
  • «Ветка содержит коммиты». Нет: ветка — 41 байт с идентификатором одного коммита, всё остальное достижимость. Из того же следует, что «удалил ветку — работа пропала» почти никогда не правда.
  • «SHA-1 гарантирует, что историю не подделали». Нет: хеш защищает от случайной порчи и обеспечивает целостность связей, от подделки защищают подписи плюс политика на сервере.
  • «git pull — это просто скачать изменения». Это fetch плюс merge (или rebase при pull.rebase=true), то есть команда, создающая объекты и двигающая вашу ветку. pull.ff=only и осознанное слияние экономят много времени.
  • «Индекс — просто список файлов для коммита». Это полное плоское дерево плюс stat-кеш плюс место, где живут конфликтные stage 1/2/3.

Практикум

Двадцать минут на настоящем рабочем репозитории дают больше, чем чтение. Три вопроса, на которые полезно уметь ответить не гугля: сколько в репозитории объектов, какой самый тяжёлый blob и когда main разошлась с origin/main.

$ git count-objects -vH                        # сколько объектов и сколько весят
$ git cat-file --batch-all-objects \
    --batch-check='%(objecttype) %(objectsize) %(objectname)' | sort -k2 -nr | head -20
$ git cat-file -p HEAD^{tree}                  # что реально лежит в корневом дереве
$ git merge-base --all main origin/main        # где ветки разошлись
$ git log --oneline --first-parent -30         # история без кухни веток
$ git reflog --date=iso | head -30             # что случилось с HEAD за последние дни
$ git fsck --unreachable --no-progress | head  # что висит без ссылок

Мини-итог

  • Git — контентно-адресуемое хранилище: адрес объекта равен SHA-1 от «тип, размер, NUL, содержимое». Имена не назначают, их вычисляют.
  • Объектов четыре: blob (байты файла), tree (каталог: режим, имя, ссылка), commit (снимок плюс родители плюс авторство), tag (аннотированный тег).
  • Коммит хранит полный снимок, а не разницу; дешёвым это делает структурное разделение — правка файла на глубине d создаёт d + 1 деревьев, один blob и один коммит.
  • Ссылка — это 41 байт. Ветка, тег, HEAD — просто указатели, всё остальное достижимость по рёбрам parent.
  • История — DAG, время в коммите на порядок не влияет; порядок родителей у merge значим, --first-parent показывает магистральную линию.
  • Объекты неизменяемы, поэтому «удаление» — потеря достижимости; reflog и git fsck дают окно в 2–4 недели, чтобы всё вернуть.
  • Packfile — компрессия дельтами поверх неизменяемых объектов, а не способ хранения истории.
  • Почти каждая команда раскладывается на три вопроса: создаёт ли объекты, двигает ли указатели, трогает ли рабочий каталог. Ответив на них, вы предскажете поведение без документации.

Источники

Что дальше

Модель данных на месте. Теперь наложим на неё ежедневный цикл: индекс и осознанный staging, частичные коммиты, чтение diff, временное откладывание работы и, главное, аккуратная отмена изменений на любом из трёх деревьев.

Ежедневная работа: staging, коммиты, diff, stash, отмена изменений

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов