voidlearn
 

1.1Объекты

Посмотришь, из каких четырёх типов объектов состоит база Git и как содержимое становится адресом объекта. Поймёшь, почему коммит хранит снимок, а diff вычисляется между снимками.

Что сможешь объяснить после этой главы
  • Что лежит в blob, tree, commit и annotated tag. Любой из них можно открыть через git cat-file.
  • Как посчитать хеш файла без Git: одной строкой в shell или функцией на Go.
  • Почему коммит ссылается на полный снимок проекта, а diff в git show каждый раз вычисляется заново.
  • Откуда git status берёт переименование, которого нет ни в одном объекте, и где обрывается история файла без --follow.
  • Что сломали в SHA-1 в 2017 году, как ответил Git и что меняет --object-format=sha256.

Словарь, где ключ вычисляют из содержимого

В каталоге .git/objects Git держит словарь. Значения в нём хранятся как байты, а ключом служит SHA-1 от этих байтов, 40 шестнадцатеричных знаков. Ключ никто не выдаёт по счётчику, его вычисляют из содержимого. Такое хранилище называют адресуемым по содержимому, ключ называют хешем или идентификатором объекта. Ветки, индекс, слияния и журнал ссылок надстроены сверху, внизу всегда этот словарь.

Потрогать его руками можно двумя служебными командами. git hash-object считает хеш данных, а с флагом -w ещё и записывает объект. git cat-file читает: -t печатает тип объекта, -s размер в байтах, -p содержимое в виде, удобном человеку.

$ git init -q demo
$ cd demo
$ echo 'hello' | git hash-object --stdin      # только посчитать
ce013625030ba8dba906f756967f9e9ca394464a
$ find .git/objects -type f                    # хранилище пустое
$ echo 'hello' | git hash-object -w --stdin   # посчитать и записать
ce013625030ba8dba906f756967f9e9ca394464a
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
$ git cat-file -t ce01362
blob
$ git cat-file -s ce01362
6
$ git cat-file -p ce01362
hello

Хеш попал прямо в путь к файлу: первые два знака стали каталогом, остальные 38 именем. Внутри файла содержимое сжато zlib, а как объекты потом собираются в pack-файлы, разобрано в главе 1.4. Обращаться к объекту можно по началу хеша, пока оно однозначно. Git и сам печатает сокращённые хеши, в небольшом репозитории по семь знаков.

Одни и те же байты дают один и тот же ключ в любом репозитории и на любой машине. Два одинаковых файла хранятся одним объектом, а чтобы узнать, есть ли у соседа объект, хватает сравнить хеши. Типов объектов четыре. Blob хранит содержимое файла, tree описывает каталог, commit фиксирует снимок проекта вместе с автором и сообщением, tag даёт объекту постоянное имя со своим автором и сообщением.

Хеш блоба можно посчитать без Git

SHA-1 считается от содержимого с заголовком. Git приклеивает спереди тип объекта, пробел, длину содержимого в байтах десятичным числом и нулевой байт. В строке hello с переводом строки шесть байтов, поэтому хешируется blob 6\0hello\n. Проверим обычной утилитой:

$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a  -
$ printf 'привет\n' | git hash-object --stdin
d0f56e135b2282bfdd87b1cae5d0238b6a193ab5
$ printf 'blob 13\0привет\n' | sha1sum   # шесть букв по 2 байта в UTF-8 и \n
d0f56e135b2282bfdd87b1cae5d0238b6a193ab5  -
$ printf 'blob 7\0привет\n' | sha1sum    # длина в символах: мимо
ed2cf1d8d16385c47dba744ed71406200ac784b4  -

На Go это одна функция. hash.Hash реализует io.Writer, так что заголовок пишется в хешер обычным fmt.Fprintf:

package main

import (
    "crypto/sha1"
    "fmt"
    "io"
    "os"
)

// blobID возвращает хеш, под которым git сохранит data как blob.
func blobID(data []byte) string {
    h := sha1.New()
    fmt.Fprintf(h, "blob %d\x00", len(data)) // заголовок: длина в байтах
    h.Write(data)                            // потом сами байты
    return fmt.Sprintf("%x", h.Sum(nil))
}

func main() {
    data, err := io.ReadAll(os.Stdin)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(blobID(data))
}
$ go run . < ../hello/main.go
76a11ee950609a691b24c9fd990544d89e4e40a4
$ git -C ../hello hash-object main.go
76a11ee950609a691b24c9fd990544d89e4e40a4

Tree, commit и tag хешируются так же, только в заголовке стоит их тип. Поэтому блоб и коммит с одинаковыми байтами получили бы разные хеши, а Git, читая объект, по первым байтам сразу узнаёт, что перед ним и сколько читать.

hash-object от файла и от потока может разойтись

git hash-object файл прогоняет содержимое через фильтры, как git add: концы строк по .gitattributes и core.autocrlf, clean-фильтры. Файл с CRLF при атрибуте text даст хеш версии с LF. Поток через --stdin без --path фильтры не проходит, для файла их отключает --no-filters. Поэтому на таких файлах hash-object --no-filters и хеш из репозитория расходятся.

Blob: содержимое без имени

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

$ git ls-tree -r HEAD
120000 blob 423782a0232338be5b86843b6774679a3da6e394    check
100644 blob e64013c8cc937938973c3492df8580d9b0f11353    docs/check.sh.example
100755 blob e64013c8cc937938973c3492df8580d9b0f11353    scripts/check.sh
$ git cat-file -p HEAD:check
scripts/check.sh

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

Файл, скопированный в пять мест, лежит в хранилище одним объектом. Если вернуть файл к прошлогодней версии байт в байт, новый объект не появится: Git возьмёт старый. А пустой каталог хранить не в чем: дерево перечисляет файлы, и git add empty/ молча ничего не добавит. Поэтому в пустые каталоги по договорённости кладут файл .gitkeep.

Tree: имена, режимы и ссылки

Tree (дерево) описывает один каталог. Это список записей, в каждой режим, тип, хеш и имя. Подкаталог выглядит записью со ссылкой на другое дерево. Возьмём маленький Go-проект из трёх файлов: go.mod, main.go и greet/greet.go. Сделаем первый коммит и спустимся от него вниз.

$ git add . && git commit -qm 'Первый коммит'
$ git cat-file -p HEAD
tree 49054b8fff495d8c56358f06542b7a14543b6804
author Аня <anya@example.com> 1788246000 +0300
committer Аня <anya@example.com> 1788246000 +0300

Первый коммит
$ git cat-file -p 49054b8
100644 blob 39c5ef80ac37708bfc5a6de808d737b8d9091e19    go.mod
040000 tree a3b4c4b42375cbca7cf23d2c71e1a7ea634efa1a    greet
100644 blob 76a11ee950609a691b24c9fd990544d89e4e40a4    main.go
$ git cat-file -p a3b4c4b
100644 blob b155bfb8e2166b7722fa4efedc6c04b138584e66    greet.go

Режим похож на права в Unix, но Git записывает всего пять значений:

РежимОбъектЧто это
100644blobобычный файл
100755blobисполняемый файл
120000blobсимволическая ссылка, в блобе путь к цели
040000treeподкаталог
160000commitsubmodule: коммит чужого репозитория, самого объекта здесь нет

От прав Unix остаётся один бит: исполняемый файл или нет. Владелец, права группы и время изменения в дерево не попадают. chmod 600 go.mod Git не заметит, а после chmod +x go.mod git diff покажет old mode 100644 и new mode 100755.

-p печатает дерево для человека. В самом объекте запись компактнее: режим текстом без ведущего нуля, пробел, имя, нулевой байт и 20 байтов хеша в двоичном виде. Это видно в выводе git cat-file tree 49054b8 | xxd. Хеш дерева считается от этих байтов, а в них входят имена, режимы и хеши детей. Два каталога с одинаковыми файлами в разных местах проекта дадут одно и то же дерево. А правка одного файла меняет хеш его каталога, каталога над ним и так до корня.

Commit: снимок и кто его сделал

Commit хранится как текст. Первая строка ссылается на корневое дерево, то есть на полный снимок проекта. Следом идут строки parent: у первого коммита их нет, у обычного одна, у merge-коммита две и больше (граф истории разобран в главе 1.2). Дальше автор, коммиттер, пустая строка и сообщение. Время записано секундами Unix с часовым поясом: 1788246000 +0300 значит 1 сентября 2026 года, 10:00 по UTC+3.

Добавим в greet/greet.go комментарий и восклицательный знак и закоммитим ещё раз:

$ git commit -qam 'Добавить восклицательный знак'
$ git cat-file -p HEAD
tree 65ee622dc0857c2a9692a922fb891175f7c9b466
parent 9a69d12ebd717c5e220cda3cb94ece4c1f6e9e9f
author Аня <anya@example.com> 1788337800 +0300
committer Аня <anya@example.com> 1788337800 +0300

Добавить восклицательный знак
$ git ls-tree -r -t --abbrev HEAD~1
100644 blob 39c5ef8     go.mod
040000 tree a3b4c4b     greet
100644 blob b155bfb     greet/greet.go
100644 blob 76a11ee     main.go
$ git ls-tree -r -t --abbrev HEAD
100644 blob 39c5ef8     go.mod
040000 tree f84cebe     greet
100644 blob 14ebcef     greet/greet.go
100644 blob 76a11ee     main.go

Второй коммит тоже ссылается на полный снимок, но новых объектов в хранилище всего четыре: блоб с новым текстом greet.go, дерево greet, корневое дерево и сам коммит. go.mod и main.go не менялись, и новое корневое дерево ссылается на те же 39c5ef8 и 76a11ee. Всего в репозитории десять объектов, и git cat-file --batch-all-objects --batch-check перечислит их все с типами и размерами.

parent commit 9a69d12 первый коммит commit 5e90c6a второй коммит tree 49054b8 tree 65ee622 blob 39c5ef8 blob 76a11ee go.mod main.go go.mod main.go greet greet tree a3b4c4b tree f84cebe greet.go greet.go blob b155bfb blob 14ebcef серые: есть только у первого коммита зелёные: записал второй коммит синие: общие, лежат в одном экземпляре
Два коммита, десять объектов. Имена файлов подписаны на стрелках, потому что хранит их дерево. Второй коммит записал четыре новых объекта и сослался на два старых блоба.

Так что Git хранит снимки. Diff из git show нигде не записан: Git сравнивает дерево коммита с деревом родителя при каждом вызове, и это дёшево, потому что записи с одинаковым хешем можно не открывать. Место на похожих версиях файлов экономит упаковка в pack-файлы (глава 1.4), модель объектов про неё не знает.

Хеш коммита считается так же, как хеш блоба, только от текста коммита. Проверить можно sha1sum, а можно собрать коммит заново низкоуровневой командой git commit-tree. Ей передают дерево, родителя и сообщение, а имена и время она, как обычный commit, читает из настроек и переменных GIT_AUTHOR_* и GIT_COMMITTER_*. Здесь в переменных стоит время второго коммита.

$ git cat-file -s HEAD
255
$ (printf 'commit 255\0'; git cat-file commit HEAD) | sha1sum
5e90c6adbe2018ea6d87ed3e11426a4e295bdde7  -
$ git commit-tree 65ee622 -p 9a69d12 -m 'Добавить восклицательный знак'
5e90c6adbe2018ea6d87ed3e11426a4e295bdde7
$ GIT_COMMITTER_DATE='2026-09-02T11:31:00+03:00' \
    git commit-tree 65ee622 -p 9a69d12 -m 'Добавить восклицательный знак'
37c1e3da319b8f7a562766999fb2878226574459

Те же дерево, родитель, люди, время и сообщение дали тот же 5e90c6a: такой объект в хранилище уже был. Время коммиттера сдвинулось на минуту, и вышел другой коммит. Хеш зависит от каждого байта снимка, родителей, имён, дат и текста сообщения.

Хеш последнего коммита заверяет всю историю

В коммите лежит хеш родителя, в родителе хеш его родителя, в каждом коммите хеш дерева, в дереве хеши файлов. Такую структуру называют графом Меркла. Поменяй байт в файле трёхлетней давности, и сменятся хеш блоба, деревьев над ним, того коммита и всех коммитов после него. Поэтому commit --amend и rebase создают новые коммиты рядом со старыми (главы 2.1 и 3.3), а хеша верхушки ветки хватает, чтобы убедиться, что у тебя та же история до байта.

Author написал изменение, committer записал этот коммит. Обычно это один человек в одну и ту же секунду. Расходятся они, когда коммит пересоздают: cherry-pick, rebase, commit --amend, git am. Вот коммит Ани после того, как Боря перенёс его через cherry-pick на ветку от того же родителя:

$ git cat-file -p HEAD
tree 65ee622dc0857c2a9692a922fb891175f7c9b466
parent 9a69d12ebd717c5e220cda3cb94ece4c1f6e9e9f
author Аня <anya@example.com> 1788337800 +0300
committer Боря <borya@example.com> 1788609600 +0300

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

Дерево, родитель, автор и сообщение совпадают с 5e90c6a до байта, но из-за строки коммиттера хеш у копии другой, 2d862ef.

Annotated tag

Четвёртый тип объекта, tag, создаёт git tag -a (или git tag -s с подписью). Получается annotated tag: объект со ссылкой на другой объект, своим автором в строке tagger, датой и сообщением. git tag без флагов объекта не создаёт, а делает lightweight tag: просто ссылку с именем прямо на коммит.

$ git tag -a v0.1.0 -m 'Первый релиз'
$ git tag v0.1.0-light
$ git cat-file -t v0.1.0
tag
$ git cat-file -p v0.1.0
object 5e90c6adbe2018ea6d87ed3e11426a4e295bdde7
type commit
tag v0.1.0
tagger Аня <anya@example.com> 1788512400 +0300

Первый релиз
$ git cat-file -t v0.1.0-light
commit
$ git rev-parse v0.1.0 v0.1.0-light 'v0.1.0^{commit}'
588621ef8b4fcb85e2a41f7ba24367cd6a24e7a0
5e90c6adbe2018ea6d87ed3e11426a4e295bdde7
5e90c6adbe2018ea6d87ed3e11426a4e295bdde7

Имя v0.1.0 ведёт к объекту тега 588621e, тот к коммиту, а суффикс ^{commit} проходит цепочку до конца. Строка type нужна, потому что пометить можно любой объект: git tag -a gomod-v0.1.0 -m '…' HEAD:go.mod даст тег с type blob. Ссылки разобраны в главе 1.2, теги для релизов и Go-модулей в главе 4.3.

Переименований Git не хранит

Ни в одном из четырёх типов нет поля «раньше этот файл назывался так-то». Посмотрим на репозиторий, где лежит store.go:

$ git mv store.go memstore.go
$ git status --short
R  store.go -> memstore.go
$ git commit -qm 'Переименовать store.go в memstore.go'
$ git ls-tree HEAD~1
100644 blob 00b62a663c45ccba5f2e42a733fac60a29e40c7c    go.mod
100644 blob 1ea630d8331159f58014d05bab7592e94b607906    store.go
$ git ls-tree HEAD
100644 blob 00b62a663c45ccba5f2e42a733fac60a29e40c7c    go.mod
100644 blob 1ea630d8331159f58014d05bab7592e94b607906    memstore.go
$ git diff-tree -r --abbrev HEAD~1 HEAD
:000000 100644 0000000 1ea630d A        memstore.go
:100644 000000 1ea630d 0000000 D        store.go
$ git diff-tree -r -M --abbrev HEAD~1 HEAD
:100644 100644 1ea630d 1ea630d R100     store.go        memstore.go

Деревья отличаются одним именем, блоб тот же. Низкоуровневая diff-tree показывает то, что есть в объектах: memstore.go появился (A), store.go исчез (D). Переименование возникает только с флагом -M, когда Git сам ищет пары «удалён - добавлен» с похожим содержимым. diff, log и show ищут такие пары по умолчанию с Git 2.9, status тоже ищет. А git mv ничего особого не записывает: mv и git add обоих путей дают тот же R в status.

Похожесть Git оценивает грубо, зато быстро. Сначала ищутся точные совпадения: одинаковый хеш блоба означает переименование без правок, R100. Для остальных пар оба файла режутся на куски по строкам (длинная строка дробится по 64 байта), и Git считает, сколько байтов старого файла дошло до нового. Их делят на размер большего из двух файлов. Порог по умолчанию 50 %, задаётся он как -M<n>, например -M40%.

$ git mv memstore.go catalog.go
$ # в catalog.go тип Store переименован в Catalog, получатель s в c
$ git add catalog.go && git status --short
R  memstore.go -> catalog.go
$ git diff --cached --stat
 memstore.go => catalog.go | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)
$ git diff --cached | head -2
diff --git a/memstore.go b/catalog.go
similarity index 52%
$ git commit -qm 'Store теперь Catalog'

Изменилось 7 строк из 31, но строки длинные: 206 байтов из 446. Поэтому похожесть уже 52 %, на волоске от порога. В копии репозитория, где ещё лежит memstore.go, повторим тот же шаг с одной лишней правкой: в Get переменная it станет item. Восьмая изменённая строка опускает оценку ниже порога:

$ git mv memstore.go catalog.go
$ git add catalog.go && git status --short
A  catalog.go
D  memstore.go
$ git diff --cached -M40% --stat
 memstore.go => catalog.go | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)
$ git diff --cached -M40% | head -2
diff --git a/memstore.go b/catalog.go
similarity index 48%
дерево HEAD~1 memstore.go 1ea630d дерево HEAD catalog.go e607bc6 1. сравнить пути одинаковых имён нет D memstore.go A catalog.go 2. хеши блобов совпадают? 1ea630d и e607bc6: нет, правка была 3. похожесть: байты старого файла, дошедшие до нового, делённые на размер большего правка в 7 строках из 31 52 %: R052, переименование правка в 8 строках из 31 48 %: остаются D и A порог по умолчанию, 50 % -M40%
Переименование каждый раз вычисляется заново. Git сравнивает пути, потом хеши, потом содержимое. Одна лишняя изменённая строка превращает переименование в удаление и новый файл.
Переименование и крупная правка в одном коммите

git mv тут не поможет. Когда похожесть ниже порога, git blame припишет все строки коммиту с переименованием, а merge ветки, где правили старый файл, закончится конфликтом CONFLICT (modify/delete) вместо тихого слияния. При 52 % те же правки сливаются сами, и blame доходит до исходных коммитов. Переименовывай отдельным коммитом, а содержимое меняй следующим: точное совпадение хешей Git находит всегда.

На том же спотыкается история файла. git log -- catalog.go отбирает коммиты, которые трогали путь catalog.go, и путь кончается на переименовании. --follow каждый раз, когда файл «появился», проверяет, не переименование ли это, и идёт дальше по старому имени:

$ git log --oneline -- catalog.go
2b8fa25 Store теперь Catalog
$ git log --oneline --name-status --follow -- catalog.go
2b8fa25 Store теперь Catalog
R052    memstore.go     catalog.go
53999bb Переименовать store.go в memstore.go
R100    store.go        memstore.go
c8f9d28 Хранилище товаров
A       store.go
$ git log --oneline --follow -M60% -- catalog.go
2b8fa25 Store теперь Catalog

Порог у --follow тот же, что у -M: при -M60% шаг через R052 уже не находится. --follow берёт только один файл, а на нелинейной истории, как предупреждает документация, справляется плохо. Чтобы git log с одним путём всегда шёл по переименованиям, есть настройка log.follow=true.

SHA-1, SHAttered и SHA-256

Всё держится на том, что разные данные дают разные хеши. Случайно получить два объекта с одним 160-битным хешем нереально. Опасна коллизия, изготовленная нарочно: подпись тега заверяет хеш, а за ним оказались бы два содержимого, одно для ревью, другое для сборки.

23 февраля 2017 года исследователи CWI Amsterdam и Google опубликовали атаку SHAttered: два разных PDF с одинаковым SHA-1. На неё ушло 263, около девяти квинтиллионов, вычислений SHA-1: 6500 лет процессорного времени и 110 лет работы видеокарт. В 2020 году авторы атаки Shambles построили коллизию с выбранным префиксом и оценили её стоимость примерно в 45 тысяч долларов. PDF из SHAttered лежат, например, в каталоге test/ репозитория sha1collisiondetection:

$ sha1sum shattered-1.pdf shattered-2.pdf
38762cf7f55934b34d179ae6a4c80cadccbb7f0a  shattered-1.pdf
38762cf7f55934b34d179ae6a4c80cadccbb7f0a  shattered-2.pdf
$ cmp shattered-1.pdf shattered-2.pdf
shattered-1.pdf shattered-2.pdf differ: char 193, line 8
$ git hash-object shattered-1.pdf shattered-2.pdf
ba9aaa145ccd24ef760cf31c74d8f7ca1a2e47b0
b621eeccd5c7edac9b7dcba35a8d5afd075e24f2

В Git эти файлы не сталкиваются: из-за заголовка blob 422435\0 SHA-1 подходит к подобранным блокам в другом внутреннем состоянии. Но коллизию можно посчитать сразу с заголовком и по той же цене, так что защищаться Git всё равно пришлось.

С версии 2.13 (май 2017) Git по умолчанию считает хеши через hardened SHA-1, библиотеку SHA-1DC Марка Стивенса (CWI) и Дэна Шумова (Microsoft). На обычных данных она выдаёт обычный SHA-1, ни один существующий хеш не поменялся. Попутно она ищет в каждом блоке следы известных атак, а если находит, Git останавливается с ошибкой SHA-1 appears to be part of a collision attack. Ложное срабатывание авторы оценивают вероятностью меньше 2-90. Что именно собрано в твой git, видно так (вывод git 2.54 из поставки macOS, в других сборках строки SHA-256 могут отличаться):

$ git version --build-options | grep -i sha
SHA-1: SHA1_DC
SHA-256: SHA256_BLK
default-hash: sha1

Hardened SHA-1 закрывает известные атаки, но от будущих спасёт только другой алгоритм. С версии 2.29 Git умеет репозитории на SHA-256:

$ git init -q --object-format=sha256 s256
$ cd s256
$ git rev-parse --show-object-format
sha256
$ echo 'hello' | git hash-object --stdin
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4
$ printf 'blob 6\0hello\n' | sha256sum
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4  -
$ echo 'hello' > f && git add f && git commit -qm 'Первый коммит'
$ git push ../bare1.git HEAD:main     # bare1.git создан на SHA-1
fatal: the receiving end does not support this repository's hash algorithm

Объекты устроены так же, меняются функция и длина хеша: 64 знака вместо 40. Формат выбирается один раз при init и записывается в конфиг репозитория как extensions.objectformat, клон его наследует. Репозитории на SHA-1 и SHA-256 друг с другом не разговаривают: ни push, ни fetch не проходят. Перевести проект можно только переписав всю историю, например через git fast-export и git fast-import в новый репозиторий, и хеши всех коммитов при этом сменятся.

В 2.42 с формата сняли пометку экспериментального, в 2.51 объявили план: Git 3.0 должен создавать новые репозитории на SHA-256. Упирается всё в экосистему: GitHub такие репозитории не поддерживает, в GitLab это эксперимент за флагом, выключенным по умолчанию. Для рабочего проекта SHA-256 годится, только если его понимает весь путь кода, от хостинга до CI.

Вопросы

4
Суть: снимки. Каждый коммит ссылается на корневое дерево всего проекта, а неизменившиеся файлы и каталоги переиспользуются по хешу. Diff нигде не записан: Git сравнивает два дерева при каждом вызове. Дельты в pack-файлах тоже есть, но это способ хранения на диске.

Что лежит в коммите

В коммите строка tree с хешем корневого дерева, строки parent, автор, коммиттер и сообщение. Списка изменённых строк там нет. Дерево перечисляет весь проект, но копировать его не нужно: неизменившийся файл даёт тот же блоб. В примере из главы второй коммит поменял один файл и добавил четыре объекта, а блобы go.mod и main.go остались общими.

Как получается diff

git show берёт дерево коммита и дерево родителя и идёт по ним параллельно. Записи с одинаковым хешем и режимом пропускаются сразу, в такие поддеревья Git даже не спускается. Для отличающихся блобов он читает оба содержимого и строит построчный diff.

$ git diff-tree --abbrev HEAD~1 HEAD      # отличается только запись greet
:040000 040000 a3b4c4b f84cebe M        greet
$ git diff-tree -r --abbrev HEAD~1 HEAD   # внутри greet отличается один блоб
:100644 100644 b155bfb 14ebcef M        greet/greet.go
$ git show --format='%h %s' HEAD | head -4
5e90c6a Добавить восклицательный знак

diff --git a/greet/greet.go b/greet/greet.go
index b155bfb..14ebcef 100644

Строка index b155bfb..14ebcef и есть два блоба, между которыми посчитана разница. Тот же механизм работает у cherry-pick и revert: чтобы перенести «изменение», его сначала вычисляют из двух снимков.

А как же место на диске

В pack-файлах похожие объекты хранятся дельтами друг от друга (глава 1.4). Но это уровень хранения: git cat-file -p всё равно отдаёт полный блоб, и модель «коммит = снимок» от упаковки не зависит.

На чём ловят

Ответ «коммит хранит патч относительно родителя» звучит правдоподобно и неверен. Проверка простая: git cat-file -p любого коммита покажет строку tree и ни одной изменённой строки кода. Поэтому, чтобы достать проект на коммите годичной давности, Git читает дерево этого коммита, а история до него не нужна.

Суть: нет. Хеш коммита считается от дерева, родителей, авторов, дат и сообщения, хеш дерева от хешей файлов. Любая правка даёт новые объекты с новыми хешами вверх по цепочке до последнего коммита. А содержимое, подложенное под старый хеш, выдаёт себя на git fsck и при передаче объектов по протоколу git.

Правка старого коммита

Поменяем сообщение первого коммита из главы и пересоберём второй коммит поверх нового с теми же деревом, автором, временем и сообщением:

$ git commit-tree 49054b8 -m 'Начальная версия'
74e5c9dc8c195156fb359c80a6263942ca237521
$ git commit-tree 65ee622 -p 74e5c9d -m 'Добавить восклицательный знак'
b4335738085fb0d3abaf84af557d75d9d7509811

Второй коммит отличается от 5e90c6a одной строкой parent, и хеш у него уже b433573. Деревья и блобы остались прежними, новых объектов два. Так работают rebase и commit --amend: старые коммиты не трогаются, рядом появляются новые, и у всех, у кого есть старая история, хеши разойдутся с твоими.

Подмена объекта на диске

Допустим, кто-то переписал файл объекта 39c5ef8 (это go.mod) так, что внутри теперь go 1.26, а имя файла осталось старым:

$ git cat-file -p 39c5ef8
module example.com/hello

go 1.26
$ git fsck
error: 80a4f6831f60541a926088ae362356f7f3273558: hash-path mismatch, found at: .git/objects/39/c5ef80ac37708bfc5a6de808d737b8d9091e19
missing blob 39c5ef80ac37708bfc5a6de808d737b8d9091e19
$ cd ..
$ git clone -q --no-local forge forge-clone   # forge: репозиторий с подменой
fatal: did not receive expected object 39c5ef80ac37708bfc5a6de808d737b8d9091e19
fatal: fetch-pack: invalid index-pack output

cat-file подделку отдал: при обычном чтении Git хеш не пересчитывает. fsck пересчитал и увидел, что содержимое на самом деле тянет на 80a4f68. Клон упал: объекты в pack-файле идут без своих хешей, получатель вычисляет хеш каждого сам, и 39c5ef8, на который ссылается дерево, так и не пришёл.

Как сказать коротко

«Объекты в Git адресуются хешем содержимого, коммит содержит хеш дерева и хеш родителя. Это граф Меркла: хеш верхушки ветки заверяет всё, что под ним. Поменять что-то в истории можно только создав новые коммиты с новыми хешами, и это видно всем, у кого есть копия».

Суть: из объектов никак: в новом дереве одно имя пропало, другое появилось. status, diff, log и merge ищут пары «удалён - добавлен» и считают их переименованием, если содержимое совпадает хотя бы на 50 %. git log -- путь фильтрует по имени и на переименовании останавливается, дальше ведёт только --follow.

Что записано в объектах

Коммит с git mv store.go memstore.go отличается от родителя одной записью в корневом дереве: имя другое, хеш блоба тот же. Больше git mv ничего не сохраняет.

Как считается похожесть

Сначала ищутся точные совпадения хешей, это R100. Для остальных пар оба файла режутся на куски по строкам, и Git делит число байтов старого файла, дошедших до нового, на размер большего файла. Если результат не ниже порога (по умолчанию 50 %, флаг -M<n>), пара становится переименованием.

Что сделали с memstore.goПохожестьgit status --short
переименовали в catalog.go100 %R memstore.go -> catalog.go
переименовали и поменяли 7 строк из 3152 %R memstore.go -> catalog.go
переименовали и поменяли 8 строк из 3148 %A catalog.go и D memstore.go

Процент считается по байтам: семь строк из 31 выглядят мелочью, но это 206 байтов из 446.

Как не потерять историю

  • Переименование отдельным коммитом, правки следующим. Точное совпадение находится всегда, и blame, log --follow и слияния пройдут через него.
  • git log --follow -- путь для одного файла или log.follow=true, чтобы так работал любой git log с одним путём.
  • Если переименование уже смешано с правкой, помогает пониженный порог: git diff -M40%, git log --follow -M40%.
Миф про git mv

«Надо переименовывать через git mv, иначе Git потеряет историю». Не потеряет и не найдёт лучше: git mv просто заменяет пару mv и git add. Историю теряют, когда в одном коммите переименовывают и сильно правят файл. Тогда даже после git mv status покажет A и D, а merge ветки со старым файлом остановится на CONFLICT (modify/delete).

Суть: для SHA-1 умеют изготавливать коллизии: SHAttered в 2017 году, коллизию с выбранным префиксом Shambles в 2020-м. С версии 2.13 Git считает hardened SHA-1, который распознаёт такие атаки и останавливает работу. С версии 2.29 есть репозитории на SHA-256, Git 3.0 планирует сделать его форматом по умолчанию. Случайных совпадений бояться не нужно.

Что именно сломано

Сломана стойкость к коллизиям: атакующий может заранее изготовить две версии данных с одинаковым хешем, безобидную и вредную. Подобрать данные под чужой готовый хеш публично никто не умеет: авторы Shambles в 2020 году отдельно отмечали, что обратить SHA-1 по-прежнему нельзя. Сценарий атаки на Git поэтому такой: злоумышленник приносит на ревью безобидную версию, её подписывают тегом, а потом тот, кто клонирует с зеркала злоумышленника, получает вторую версию с тем же хешем и той же подписью.

Что сделал Git

  • Hardened SHA-1 (Git 2.13, май 2017). Библиотека SHA-1DC на обычных данных даёт тот же SHA-1, а на данных со следами атаки прерывает работу с ошибкой SHA-1 appears to be part of a collision attack. Собрана ли она в твой git, покажет git version --build-options: строка SHA-1: SHA1_DC.
  • Проверка при получении объектов. Если пришёл объект с хешем, который уже есть локально, index-pack сравнивает содержимое побайтно и при расхождении падает с SHA1 COLLISION FOUND.
  • SHA-256 (Git 2.29, октябрь 2020). git init --object-format=sha256, хеши по 64 знака. В 2.42 с формата сняли пометку экспериментального, в 2.51 объявили, что Git 3.0 будет создавать такие репозитории по умолчанию.

Почему все до сих пор на SHA-1

Формат выбирается при init (флагом, GIT_DEFAULT_HASH или init.defaultObjectFormat), и репозитории разных форматов не обмениваются объектами: push падает с the receiving end does not support this repository's hash algorithm. Сменить формат у готового проекта можно только переписав историю, все хеши коммитов станут новыми. А GitHub SHA-256 не поддерживает, в GitLab это эксперимент за флагом, выключенным по умолчанию.

Два неверных ответа

«Git уже перешёл на SHA-256» - нет, в git 2.54 новый репозиторий по-прежнему создаётся на SHA-1, переход намечен на 3.0. «Файлы SHAttered ломают Git» - тоже нет: из-за заголовка blob <размер>\0 git hash-object даёт им разные хеши. Для атаки на Git коллизию надо считать заново, уже с заголовком, и именно такие попытки ловит hardened SHA-1.

Что стоит запомнить

  • Blob хранит содержимое файла без его имени.
  • Tree связывает режим, имя и объект; commit указывает на tree и родителей.
  • Объект адресуется хешем заголовка и содержимого.

Частые ошибки

  • Git не хранит переименование как отдельный объект.
  • Diff - вычисление между снимками, а не объект commit.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Разбери объект коммита

Что показывает git cat-file -p HEAD и где искать байты файла main.go?

tree 0b919d88a591bd39ee0b8e37efc92e5ab949dc31
author Fixture <fixture@example.test> 1767225600 +0000
committer Fixture <fixture@example.test> 1767225600 +0000

base
Ответ и разбор

Это root commit object: он содержит ссылку на корневой tree, author, committer и сообщение; строки parent здесь нет, потому что у root commit нет родителя. Байтов файла здесь нет. Найди путь через tree и прочитай blob, например git cat-file -p HEAD:main.go.

Код-ревью

2. Не ищи имя файла в blob

Разработчик утверждает, что blob хранит имя файла, и проверяет так:

git cat-file -p <blob-hash> | grep main.go

Почему проверка не найдёт имя и какой объект надо посмотреть?

Ответ и разбор

Blob хранит только байты содержимого, поэтому имя main.go там не обязано встречаться. Посмотри tree object: там лежат имена, режимы и ссылки на дочерние объекты. Blob проверь отдельно на содержимое файла.

Применение

3. Объясни одинаковые файлы

Два разных пути в одном коммите показывают один blob hash. Что это говорит о содержимом и чего не говорит об именах файлов?

Ответ и разбор

В обоих путях лежат одинаковые байты, поэтому Git может ссылаться на один объект. Имена находятся в tree entries, а не в blob. Совпадение хеша не означает, что это hard link файловой системы.

Применение

4. Различи снимок и patch

Коммит содержит tree и родителя. Почему git show может вычислить patch, хотя отдельного patch object среди четырёх типов нет?

Ответ и разбор

Git сравнивает tree коммита с tree родителя и строит patch при запросе. Commit фиксирует снимок через дерево и ссылку на родителя; patch - представление различий, вычисленное для показа.