Как устроен Git
Под десятками команд Git лежит небольшая модель: неизменяемые объекты, адресуемые хешем
содержимого, ссылки с именами поверх них и индекс между последним коммитом и рабочей копией.
Статья идёт снизу вверх. Сначала объекты, из которых собран каждый коммит, потом ветки,
HEAD и граф истории, потом индекс и три дерева, между которыми файлы перемещают
add, commit и switch. В конце разберём, как
всё это лежит на диске: pack-файлы, сборка мусора, неглубокие и частичные клоны. После этого любая
команда из следующих статей читается как операция над объектами, ссылками и индексом.
Понимаешь ли ты, что происходит под командами. «Ветка указывает на коммит», «коммит хранит снимок,
а diff вычисляется», «git add записывает блоб и обновляет индекс», «после
reset --hard коммит никуда не делся, его найдёт reflog». Так отвечает человек, который
сам разберётся в любом странном состоянии репозитория. Кто знает только команды, в такой ситуации
удаляет каталог и клонирует заново, теряя всё, что не успел запушить.
1.1Объекты
Всё, что Git знает о проекте, лежит в хранилище объектов: в него кладут байты, получают назад их хеш и по этому хешу потом достают байты обратно. Файлы, каталоги, коммиты и теги хранятся в нём объектами четырёх типов. Из того, как объекты ссылаются друг на друга, следует многое: почему историю нельзя тихо подправить, почему Git не помнит переименований и чем ему грозит взломанный SHA-1.
- Что лежит в 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 шестнадцатеричных знаков. Ключ никто не выдаёт по счётчику, его
вычисляют из содержимого. Такое хранилище называют адресуемым по содержимому
(content-addressable storage), ключ называют хешем или идентификатором объекта. Ветки,
индекс, слияния и журнал ссылок надстроены сверху, внизу всегда этот словарь.
Потрогать его руками можно двумя служебными командами. 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, читая объект, по первым байтам сразу узнаёт, что перед ним и сколько читать.
git hash-object файл прогоняет содержимое через фильтры, как git add:
концы строк по .gitattributes и core.autocrlf, clean-фильтры. Файл с CRLF
при атрибуте text даст хеш версии с LF. Поток через --stdin без
--path фильтры не проходит, для файла их отключает --no-filters. Поэтому на
таких файлах hash-object --no-filters и хеш из репозитория расходятся.
Blob: содержимое без имени
Blob (binary large object) хранит байты файла, и больше в нём ничего нет: ни имени, ни пути,
ни прав доступа, ни даты изменения. Проверить легко. Положим в репозиторий скрипт с правом на
исполнение, его копию без этого права и символическую ссылку 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 записывает всего пять значений:
| Режим | Объект | Что это |
|---|---|---|
100644 | blob | обычный файл |
100755 | blob | исполняемый файл |
120000 | blob | символическая ссылка, в блобе путь к цели |
040000 | tree | подкаталог |
160000 | commit | submodule: коммит чужого репозитория, самого объекта здесь нет |
От прав 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 перечислит их все с типами и размерами.
Так что 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: такой объект
в хранилище уже был. Время коммиттера сдвинулось на минуту, и вышел другой коммит. Хеш зависит от
каждого байта снимка, родителей, имён, дат и текста сообщения.
В коммите лежит хеш родителя, в родителе хеш его родителя, в каждом коммите хеш дерева, в дереве
хеши файлов. Такую структуру называют графом Меркла (Merkle DAG). Поменяй байт в файле трёхлетней
давности, и сменятся хеш блоба, деревьев над ним, того коммита и всех коммитов после него. Поэтому
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%
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Что лежит в коммите
В коммите строка 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.go | 100 % | R memstore.go -> catalog.go |
| переименовали и поменяли 7 строк из 31 | 52 % | R memstore.go -> catalog.go |
| переименовали и поменяли 8 строк из 31 | 48 % | 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 потеряет историю». Не потеряет и не
найдёт лучше: git mv просто заменяет пару mv и git add. Историю теряют, когда в одном
коммите переименовывают и сильно правят файл. Тогда даже после git mv
status покажет A и D, а merge ветки со старым файлом
остановится на CONFLICT (modify/delete).
Что именно сломано
Сломана стойкость к коллизиям: атакующий может заранее изготовить две версии данных с одинаковым хешем, безобидную и вредную. Подобрать данные под чужой готовый хеш публично никто не умеет: авторы 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.
1.2Ссылки и граф коммитов
Объекты из главы 1.1 адресуются хешами, но набирать сорок шестнадцатеричных знаков никто не станет.
Поверх объектов git держит ссылки: ветки, теги и HEAD дают коммитам имена, и почти любая
операция с историей сводится к тому, что какое-то имя начинает указывать на другой коммит. Сами
коммиты связаны хешами родителей в граф, и по нему считаются HEAD~2, HEAD^2
и диапазоны вроде main..feature.
- Что физически стоит за веткой и почему файла
.git/refs/heads/mainиногда нет. - Кого двигает коммит: ветку, а не HEAD. И куда деваются коммиты, сделанные в detached HEAD.
- Чем
HEAD~2отличается отHEAD^2и зачем у merge-коммита порядок родителей. - Что покажут
git log main..featureиgit log main...featureи почемуgit diffчитает те же точки по-своему.
Ссылка: имя, за которым записан хеш
Примеры главы идут на одном демо-репозитории: Go-сервис shop с ветками main,
retry, timeout и аннотированным тегом v0.1.0. Его граф есть
на схемах ниже. Ветка main лежит в нём обычным файлом:
$ cat .git/refs/heads/main
b6fcc4a26066e71e4a81595bf011dbbea13d9dce
$ git for-each-ref
b6fcc4a26066e71e4a81595bf011dbbea13d9dce commit refs/heads/main
252fe58338e3e41e8fb4f9c489aab27d73b23636 commit refs/heads/retry
f19ab7b1847a76844ca38626faab1ba2164d04b8 commit refs/heads/timeout
33d2d981809887d6f96df33035f58b0320783daf tag refs/tags/v0.1.0
В файле 41 байт: хеш коммита и перевод строки. Списка коммитов ветки там нет, даты создания и автора
тоже. Поэтому ветка создаётся одной записью на диск, а «коммиты ветки» git каждый раз вычисляет
заново, идя от этого хеша по родителям. Тег v0.1.0 смотрит не на коммит, а на объект
тега (tag во второй колонке), как и положено аннотированному тегу. Первый уровень под
.git/refs задаёт вид ссылки:
| Префикс | Что там лежит | Кто двигает |
|---|---|---|
refs/heads/ | локальные ветки | твои commit, merge, rebase, reset |
refs/tags/ | теги | никто: тег по договорённости не переносят (глава 4.3) |
refs/remotes/origin/ | remote-tracking ветки: что твой клон последним узнал о ветках сервера | fetch, push и служебные вроде git remote prune; своим коммитом их не сдвинешь (глава 4.1) |
refs/stash | последняя запись stash | git stash (глава 2.2) |
refs/pull/<n>/head, refs/merge-requests/<iid>/head | голова pull request на GitHub и merge request на GitLab | сервер; в клоне их нет, пока не скачаешь явно |
Короткое имя git разворачивает по правилам из git help revisions. Для main он
проверяет по очереди .git/main (так находятся HEAD и ORIG_HEAD),
refs/main, refs/tags/main, refs/heads/main,
refs/remotes/main и последним refs/remotes/main/HEAD. Тег стоит в списке
раньше ветки, и это вылезает, если завести ветку с именем тега:
$ git branch v0.1.0 retry
$ git switch v0.1.0
warning: refname 'v0.1.0' is ambiguous.
Switched to branch 'v0.1.0'
$ git log --oneline -1 v0.1.0
warning: refname 'v0.1.0' is ambiguous.
5034bfd (tag: v0.1.0) Добавить /health
switch переключился на ветку, а log с тем же именем показал коммит тега, хотя
HEAD стоит на 252fe58. Git предупредил и всё равно выполнил. Однозначно работает полное имя,
heads/v0.1.0 или refs/heads/v0.1.0, и в скриптах лучше писать только его.
Слеш в имени ссылки превращается в каталог, так что ветки feature и
feature/retry вместе не живут: файл и каталог с одним именем в одной папке не лежат (в
формате reftable, о котором ниже, запрет сохранили).
$ git branch feature
$ git branch feature/retry
fatal: cannot lock ref 'refs/heads/feature/retry': 'refs/heads/feature' exists; cannot create 'refs/heads/feature/retry'
Руками в эти файлы писать незачем. git update-ref refs/heads/hotfix 5034bfd создаёт ветку
так же, как git branch. В отличие от echo в файл он берёт блокировку
hotfix.lock и пишет запись в reflog (журнал перемещений ссылки, глава 5.2), а с третьим
аргументом сдвинет ссылку, только если она всё ещё стоит на ожидаемом коммите.
packed-refs: у ветки может не быть файла
Тысячи тегов в виде тысяч крошечных файлов занимают место и медленно читаются. Поэтому
git pack-refs --all складывает ссылки в один текстовый файл .git/packed-refs и
удаляет отдельные файлы. git gc (глава 1.4) делает это сам.
$ git pack-refs --all
$ find .git/refs -type f
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
b6fcc4a26066e71e4a81595bf011dbbea13d9dce refs/heads/main
252fe58338e3e41e8fb4f9c489aab27d73b23636 refs/heads/retry
f19ab7b1847a76844ca38626faab1ba2164d04b8 refs/heads/timeout
33d2d981809887d6f96df33035f58b0320783daf refs/tags/v0.1.0
^5034bfdb61fd85a2a1ea1b48514fec2bfae1962c
find не нашёл ни одного файла, а git rev-parse main отвечает как раньше.
Строка с крышкой ^ хранит коммит, на который указывает тег строкой выше, чтобы не читать
объект тега при каждом обращении. Теперь закоммитим в ветку после упаковки:
$ git switch -q retry
$ echo "// TODO: пауза между попытками" >> retry.go
$ git commit -qam "Оставить TODO про паузу"
$ find .git/refs -type f
.git/refs/heads/retry
$ grep retry .git/packed-refs
252fe58338e3e41e8fb4f9c489aab27d73b23636 refs/heads/retry
$ git rev-parse retry
f593e345af9ea58c9d28e6329fd383fa16e10295
Коммит заново создал файл ветки, а в packed-refs осталось старое значение. Побеждает файл:
git ищет ссылку сначала в каталоге и только потом в packed-refs. Свежий
git clone вообще пишет ссылки сервера сразу в packed-refs. Так что
cat .git/refs/heads/main годится посмотреть глазами, а скрипт должен читать ссылки через
git rev-parse или git for-each-ref.
Такой формат хранения называется files. С git 2.45 есть второй, reftable:
git init --ref-format=reftable кладёт ссылки и их журналы в двоичные таблицы в
.git/reftable/, а готовый репозиторий переводит git refs migrate (с 2.46).
В .git/HEAD там лежит заглушка ref: refs/heads/.invalid. В Git 3.0 reftable
собираются сделать форматом по умолчанию, git 2.54 всё ещё создаёт files.
Опыты выше меняли демо-репозиторий, поэтому дальше снова берём его исходный вид.
На macOS и Windows files подводит: файловая система там по умолчанию не различает регистр,
и timeout с Timeout для неё один файл:
$ git branch Retry main
fatal: a branch named 'Retry' already exists
$ git pack-refs --all
$ git branch Timeout main
$ git branch -v
Timeout b6fcc4a Перейти на Go 1.27
* main b6fcc4a Перейти на Go 1.27
retry 252fe58 Ограничить число повторов
timeout f19ab7b Брать таймаут из окружения
$ git log --oneline -1 timeout
b6fcc4a (HEAD -> main, Timeout) Перейти на Go 1.27
Пока retry лежит файлом, git честно отказывает. После упаковки файла timeout
нет, проверка проходит, и появляется файл Timeout. Дальше команды расходятся:
git branch -v находит timeout в packed-refs на
f19ab7b, а git log timeout открывает refs/heads/timeout, получает
от файловой системы Timeout и уходит на b6fcc4a. На Linux, где обычно
крутится CI, всё выглядит нормально. Не заводи такие ветки; в формате reftable этой беды нет.
HEAD: ссылка на ссылку
HEAD отвечает на вопрос «где я сейчас». Внутри у него обычно не хеш, а имя ветки. Ссылку,
которая указывает на другую ссылку, называют символической. Посмотри, какой из двух файлов
меняет коммит (перед ним в go.mod поднята версия Go):
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
c189a4cb7594bac819d3382931a81b621d337a1f
$ git commit -am "Перейти на Go 1.27"
[main b6fcc4a] Перейти на Go 1.27
1 file changed, 1 insertion(+), 1 deletion(-)
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
b6fcc4a26066e71e4a81595bf011dbbea13d9dce
.git/HEAD не изменился ни на байт. git commit записал новый коммит, родителем
поставил c189a4c (туда вёл HEAD) и переписал файл ветки, на которую HEAD указывает. Причём
со сверкой: git передаёт старое значение, и если ветку за это время сдвинул другой процесс, чужая
правка не затрётся. Отсюда привычное «ветка растёт»: коммит двигает текущую ветку, а HEAD едет вместе
с ней, потому что смотрит на её имя. git switch timeout устроен наоборот: ветки не трогает,
обновляет индекс и рабочую копию (глава 1.3) и пишет в .git/HEAD строку
ref: refs/heads/timeout.
Символическая ссылка может указывать на ветку, которой ещё нет. В новом репозитории HEAD уже содержит имя ветки, а файла ветки нет, ей не на что указывать:
$ git init -q -b main shop
$ cd shop
$ cat .git/HEAD
ref: refs/heads/main
$ git log
fatal: your current branch 'main' does not have any commits yet
Такую ветку называют unborn, «ещё не рождённой», файл появится с первым коммитом. Есть символическая
ссылка и среди remote-tracking веток: после клонирования refs/remotes/origin/HEAD содержит
ref: refs/remotes/origin/main, ветку сервера по умолчанию. По последнему правилу
разворачивания имён голое origin в команде означает origin/main.
Detached HEAD: коммиты без ветки
Когда в .git/HEAD записан хеш, а не имя ветки, HEAD называют отсоединённым
(detached). Так бывает после переключения не на ветку, а на тег, хеш коммита или remote-tracking
ветку. git switch без флага на это не пойдёт:
$ git switch v0.1.0
fatal: a branch is expected, got tag 'v0.1.0'
hint: If you want to detach HEAD at the commit, try again with the --detach option.
$ git switch --detach v0.1.0
HEAD is now at 5034bfd Добавить /health
$ cat .git/HEAD
5034bfdb61fd85a2a1ea1b48514fec2bfae1962c
$ git branch
* (HEAD detached at v0.1.0)
main
retry
timeout
git checkout v0.1.0 отсоединяет HEAD без флагов и только печатает пояснение. Само
состояние ничего не ломает: так смотрят старую версию или гоняют тесты на чужом коммите. Git и сам
отсоединяет HEAD на время rebase и bisect (в git branch это
(no branch, rebasing timeout)), а git submodule update по умолчанию оставляет
подмодуль в detached HEAD на записанном коммите.
Опасно становится после коммита. Механика та же, что на ветке, только двигать некого, и git
переписывает сам .git/HEAD:
$ git commit -m "Попробовать другой роутер"
[detached HEAD 89f82f1] Попробовать другой роутер
1 file changed, 3 insertions(+)
create mode 100644 router.go
$ git status
HEAD detached from v0.1.0
nothing to commit, working tree clean
$ git switch main
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
89f82f1 Попробовать другой роутер
If you want to keep it by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> 89f82f1
Switched to branch 'main'
В статусе at сменилось на from: HEAD ушёл с коммита, на который
переключались. После switch main на 89f82f1 не ведёт ни одна ссылка. Объект
цел, но git log --all обходит ссылки и его уже не покажет, а со временем
git gc удалит его совсем, по умолчанию не раньше чем через 30 дней (сроки в главах 1.4 и
5.2).
Пока HEAD на новом коммите, хватит git switch -c router-try: ветка появится прямо здесь, и
HEAD снова станет символическим. Уже переключился, возьми хеш из предупреждения:
git branch router-try 89f82f1. А если предупреждение уже уехало из терминала, коммит найдётся в
git reflog: он помнит каждое перемещение HEAD (подробно в главе 5.2).
История как граф: корень, слияние, достижимость
Коммит хранит хеши родителей и ничего не знает о потомках: связи в истории смотрят назад во
времени. Замкнуть их в цикл нельзя. Хеш родителя входит в содержимое потомка, значит, родитель записан
раньше, а хеш потомка до его записи никому не известен. Такая структура называется направленным
ациклическим графом, DAG (directed acyclic graph). git log --graph рисует его
псевдографикой, новые коммиты сверху:
$ git log --oneline --graph --all
* b6fcc4a (HEAD -> main) Перейти на Go 1.27
| * f19ab7b (timeout) Брать таймаут из окружения
| * 5796c9e Добавить таймаут клиента
|/
* c189a4c Описать запуск в README
* dab01e5 Merge branch 'retry'
|\
| * 252fe58 (retry) Ограничить число повторов
| * a99a9ea Повторять запрос к базе
* | 386ce70 Логировать запросы
|/
* 5034bfd (tag: v0.1.0) Добавить /health
* 2ca63de Создать модуль shop
У 2ca63de родителей нет, это корневой коммит (root): в
git cat-file -p 2ca63de нет ни одной строки parent. Корней может быть
несколько. git switch --orphan docs начинает ветку без истории, и её первый коммит тоже
станет корнем. Слить две истории без общего предка git откажется
(fatal: refusing to merge unrelated histories), пока не добавишь
--allow-unrelated-histories: флаг нужен, когда объединяют два проекта, начатых независимо.
У dab01e5 родителей два, это merge-коммит:
$ git cat-file -p dab01e5
tree 0afba99949a509b6ddd89a30d6b0d37d11226500
parent 386ce708aaa3781c2d94245ad244b797d0334557
parent 252fe58338e3e41e8fb4f9c489aab27d73b23636
author Аня <anya@example.com> 1788249000 +0300
committer Аня <anya@example.com> 1788249000 +0300
Merge branch 'retry'
Порядок строк parent не случаен. Первым записан коммит, на котором стояла текущая ветка
(386ce70 из main), вторым то, что вливали (252fe58 из
retry). На этом держится git log --first-parent: он идёт только по первым
родителям и показывает main так, будто вся работа в retry уместилась в один
коммит-слияние:
$ git log --oneline --first-parent
b6fcc4a (HEAD -> main) Перейти на Go 1.27
c189a4c Описать запуск в README
dab01e5 Merge branch 'retry'
386ce70 Логировать запросы
5034bfd (tag: v0.1.0) Добавить /health
2ca63de Создать модуль shop
Из того же устройства следует, что коммит не принадлежит ветке. Ветка указывает на одну вершину, а «коммиты ветки» означают всё, что из этой вершины достижимо по родителям:
$ git branch --contains a99a9ea
* main
retry
timeout
$ git branch --contains 386ce70
* main
timeout
a99a9ea делали в retry, но после слияния он достижим и из main,
и из timeout, которая ответвилась позже. А 386ce70 из retry
недостижим: сливали retry в main, не наоборот. В какой ветке коммит
создавали, по графу не узнать, такого поля в объекте нет. Подсказку даёт разве что сообщение
merge-коммита.
~ и ^: сколько шагов и какой родитель
Обе записи ставятся после имени коммита и ведут к предкам, но число после них значит разное.
X~n делает n шагов назад и на каждом берёт первого родителя. X^n делает
ровно один шаг, к n-му родителю. Без числа обе означают единицу, поэтому main~ и
main^ ведут к одному коммиту. Расходятся записи на merge-коммите.
~ шагает по верхней линии первых родителей,
^2 сворачивает ко второму родителю merge-коммита.| Запись | Маршрут от main (b6fcc4a) | Коммит |
|---|---|---|
main~, main^ | шаг к первому родителю | c189a4c |
main~2, main^^ | два шага по первым родителям | dab01e5, merge-коммит |
main~3, main~2^1 | за merge-коммитом к первому родителю | 386ce70 |
main~2^2 | за merge-коммитом ко второму родителю | 252fe58 |
main~2^2~1 | и ещё шаг назад по бывшей ветке retry | a99a9ea |
main^2 | второй родитель у b6fcc4a, которого нет | ошибка |
Длинную запись читай слева направо как маршрут: main~2^2~1 значит «два шага по первым
родителям, свернуть ко второму родителю, ещё шаг назад». ^^ равно ~2, а
^2 на коммите с одним родителем даёт сообщение, которое сбивает с толку:
$ git log main^2
fatal: ambiguous argument 'main^2': unknown revision or path not in the working tree.
Use '--' to separate paths from revisions, like this:
'git <command> [<revision>...] -- [<file>...]'
Про отсутствующего родителя ни слова: git не нашёл такой ревизии и допустил, что main^2
может оказаться путём к файлу. Так же выглядит main~6, если история короче шести шагов.
Крышка умеет и снимать тег: v0.1.0 указывает на объект тега 33d2d98, а
v0.1.0^{} доходит до коммита 5034bfd. Для merge-коммита есть запись, которая
сразу строит диапазон из следующего раздела: dab01e5^- означает
dab01e5^1..dab01e5, то есть слияние и всё, что пришло с влитой веткой:
$ git log --oneline dab01e5^-
dab01e5 Merge branch 'retry'
252fe58 (retry) Ограничить число повторов
a99a9ea Повторять запрос к базе
В zsh с включённой опцией extendedglob крышка участвует в
шаблонах имён файлов, и git show HEAD^ падает ещё в оболочке с
no matches found: HEAD^. Помогает HEAD~ или кавычки.
Диапазоны A..B и A...B
Команды, которые обходят историю (log, rev-list, shortlog),
понимают одиночную ревизию как множество: сам коммит и всё, что из него достижимо.
git rev-list --count timeout отвечает 9, это все коммиты графа, кроме b6fcc4a.
Диапазон задаёт множество вычитанием. A..B сокращает запись ^A B: всё
достижимое из B минус всё достижимое из A, то есть «что есть в B и чего нет в A»:
$ git log --oneline main..timeout
f19ab7b (timeout) Брать таймаут из окружения
5796c9e Добавить таймаут клиента
$ git log --oneline timeout..main
b6fcc4a (HEAD -> main) Перейти на Go 1.27
$ git log --oneline 5034bfd..c189a4c
c189a4c Описать запуск в README
dab01e5 Merge branch 'retry'
386ce70 Логировать запросы
252fe58 (retry) Ограничить число повторов
a99a9ea Повторять запрос к базе
Последний пример ломает привычку читать A..B как «коммиты между A и B». Коммиты
retry не лежат на прямой линии от 5034bfd к c189a4c, но из
c189a4c они достижимы через второго родителя merge-коммита, а из 5034bfd нет.
Пустой вывод тоже ответ: git log main..retry ничего не печатает, потому что всё из
retry уже достижимо из main. На той же проверке построен
git branch --merged main, который здесь выводит main и retry.
A...B даёт симметрическую разность: коммиты, достижимые из A или из B, но не из обоих
сразу. Граница проходит по merge-base, ближайшему общему предку (как его ищут, разобрано в главе 3.1).
--left-right помечает сторону каждого коммита, --boundary показывает границу:
$ git log --oneline --graph --left-right --boundary main...timeout
< b6fcc4a (HEAD -> main) Перейти на Go 1.27
| > f19ab7b (timeout) Брать таймаут из окружения
| > 5796c9e Добавить таймаут клиента
|/
o c189a4c Описать запуск в README
$ git rev-list --left-right --count main...timeout
1 2
c189a4c. Снизу 5034bfd..c189a4c, куда попали и коммиты retry.
Слева, в main, один коммит, справа два. Так получаются «ahead» и «behind»:
git status внутри запускает тот же обход --left-right между веткой и её
upstream. Любую сторону диапазона можно опустить, на её место встанет HEAD. В клоне с одним
неотправленным коммитом:
$ git log --oneline origin/main..
65b6085 (HEAD -> main) Добавить TODO
$ git status -sb
## main...origin/main [ahead 1]
git diff понимает точки иначе, потому что сравнивает два снимка, а не множества коммитов.
git diff A..B значит ровно то же, что git diff A B. git diff A...B
сравнивает с B их merge-base и показывает только сделанное в B после развилки:
$ git diff --stat main..timeout
client.go | 17 +++++++++++++++++
go.mod | 2 +-
2 files changed, 18 insertions(+), 1 deletion(-)$ git diff --stat main...timeout
client.go | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
В log три точки шире двух: к коммитам B добавляются коммиты A после развилки. В
diff они, наоборот, выбрасывают всё, что появилось в A после развилки. Слева
go.mod попал в разницу, хотя timeout его не трогала: это коммит
b6fcc4a из main, прочитанный задом наперёд. Pull request на GitHub показывает
diff с тремя точками. Чтобы не путаться, пиши явно: git diff --merge-base main timeout (git
2.30) даёт то же, что git diff main...timeout.
Вопросы
4.git/refs/heads/
или строка в .git/packed-refs. HEAD хранит имя текущей ветки, то есть это символическая
ссылка. Коммит сдвигает ветку, на которую смотрит HEAD, и не трогает сам HEAD. switch
делает наоборот.Что лежит на диске
В .git/HEAD строка ref: refs/heads/main, в файле ветки 41 байт: хеш и
перевод строки. Ни списка коммитов, ни даты создания. «Коммиты ветки» git находит, шагая от хеша по
родителям, поэтому один коммит входит сразу в несколько веток, а удаление ветки удаляет только имя.
По той же причине git branch -d сначала проверяет, влита ли ветка, и откажет с
error: the branch 'timeout' is not fully merged, пока не скажешь -D.
Файла может и не быть: после git gc или git clone ссылки лежат строками в
packed-refs, и скрипту надёжнее спросить git rev-parse.
Что делает git commit
- Собирает из индекса объекты tree. Содержимое файлов уже лежит в blob-ах, их записал
git add. - Записывает объект коммита, родителем ставит коммит, к которому сейчас ведёт HEAD.
- Обновляет ссылку
HEAD, и раз она символическая, запись проходит насквозь вrefs/heads/main. Git делает это транзакцией: берёт блокировкиHEAD.lockиmain.lock, сверяет старое значение, пишет запись в reflog.
Что делает git switch
git switch timeout приводит индекс и рабочую копию к коммиту ветки и пишет в
.git/HEAD строку ref: refs/heads/timeout. Ни одна ветка не сдвигается.
git switch -c fix перед этим создаёт refs/heads/fix на текущем коммите. А
если переключиться на тег или хеш, в .git/HEAD ляжет хеш, и это уже detached HEAD.
.git/HEAD лежит хеш коммита, а не имя ветки. Попадают
туда переключением на тег, хеш или remote-tracking ветку, а ещё git сам отсоединяет HEAD на время
rebase и bisect. Коммит в этом состоянии двигает только HEAD, и после ухода на ветку на него не
остаётся ссылок. Спасает ветка: git switch -c до ухода, git branch имя хеш
после, а хеш хранит reflog.Как туда попадают
git checkout v0.1.0 или git checkout origin/main отсоединяют HEAD без
вопросов, git switch требует --detach. Сам git отсоединяет HEAD на время
rebase и bisect, а git submodule update ставит подмодуль на
записанный коммит, и коммит внутри подмодуля без ветки теряется тем же способом.
Что происходит с коммитом
Коммит создаётся как обычно, но сдвинуть git может только HEAD: в .git/HEAD ложится
новый хеш, ветки стоят на местах. После git switch main на коммит нет ссылок:
git log --all его не показывает, git branch --contains молчит. При уходе
git печатает Warning: you are leaving 1 commit behind вместе с хешем. Объект цел и
удаляется только сборкой мусора, по умолчанию не раньше чем через 30 дней.
Как вернуть
До ухода хватит git switch -c router-try, после нужна ветка на хеше из предупреждения
или из reflog:
$ git reflog -3
b6fcc4a (HEAD -> main) HEAD@{0}: checkout: moving from 89f82f14ffc12e0372b6d8a8729b448b5d61a9a3 to main
89f82f1 HEAD@{1}: commit: Попробовать другой роутер
5034bfd (tag: v0.1.0) HEAD@{2}: checkout: moving from main to v0.1.0
$ git branch router-try 89f82f1
HEAD@{1} через минуту значит другое
Номер в HEAD@{n} отсчитывается от последнего перемещения HEAD, и каждый
switch сдвигает нумерацию. Сразу после ухода HEAD@{1} указывает на
89f82f1, а после ещё одного git switch timeout уже на b6fcc4a.
Бери из reflog хеш, а не номер. И reflog локальный: в чужом клоне твоих перемещений HEAD нет.
~n делает n шагов назад по первым родителям,
^n делает один шаг к n-му родителю. На линейной истории HEAD~2 даёт деда, а
HEAD^2 даёт ошибку: второго родителя нет. Первым родителем merge-коммита записана ветка, в
которую вливали, и на этом держатся --first-parent и revert -m 1.Правило
X~, X^ и X~1 означают первого родителя, X~3 равно
X^^^, а X^2 имеет смысл только у merge-коммита. Записи складываются в
маршрут и читаются слева направо. В демо-репозитории main~2 указывает на merge-коммит
dab01e5:
$ git rev-parse --short main~3
386ce70
$ git rev-parse --short main~2^1
386ce70
$ git rev-parse --short main~2^2
252fe58
$ git rev-parse --short main~2^2~1
a99a9ea
main~3 и main~2^1 совпадают, потому что оба идут по первым родителям.
main~2^2 сворачивает в бывшую ветку retry, и следующий ~1 идёт
уже по ней.
Зачем порядок родителей
git log --first-parentпоказывает основную линию, где каждая влитая ветка выглядит одним коммитом;git diff M^1 Mпоказывает, что слияние принесло в основную ветку, аgit log M^-выводит сам merge-коммит и коммиты влитой ветки;git bisect start --first-parent(git 2.29) идёт только по первой линии и внутрь влитых веток не заходит;git revert -m 1 Mоткатывает слияние относительно первого родителя (глава 5.1).
$ git diff --stat dab01e5^1 dab01e5
retry.go | 13 +++++++++++++
1 file changed, 13 insertions(+)
$ git diff --stat dab01e5^2 dab01e5
log.go | 7 +++++++
1 file changed, 7 insertions(+)
Один коммит, два ответа на вопрос «что изменилось»: для main слияние добавило
retry.go, для retry добавило log.go.
Порядок родителей зависит от того, где запускали merge. Влей main в
timeout, потом перемотай main через fast-forward (глава 3.1), и первым
родителем нового merge-коммита окажется f19ab7b из timeout. После этого
git log --first-parent main идёт через коммиты timeout, а
b6fcc4a, бывшая вершина main, из основной линии пропадает.
log обе записи задают множества коммитов.
A..B даёт «что есть в B и нет в A», A...B даёт достижимое ровно с одной
стороны, граница проходит по merge-base. В diff точки означают концы сравнения:
A..B равно A B, а A...B сравнивает merge-base с B.Две точки
git log A..B можно записать как git log ^A B или git log B --not A.
Это не «коммиты между A и B по времени»: боковая ветка, влитая в B, тоже попадёт в вывод. На практике
git log main..feature показывает, что уедет в merge request, а пустой вывод значит, что
ветка уже влита.
Три точки
git log A...B выводит коммиты, достижимые из A или из B, но не из обоих. С
--left-right видно сторону каждого, а подсчёт по сторонам и есть «ahead/behind» из
git status:
$ git log --oneline --left-right main...timeout
< b6fcc4a (HEAD -> main) Перейти на Go 1.27
> f19ab7b (timeout) Брать таймаут из окружения
> 5796c9e Добавить таймаут клиента
$ git rev-list --left-right --count main...timeout
1 2
В git diff
| Запись | git log | git diff |
|---|---|---|
A B | всё, что достижимо из A или из B | снимок A против снимка B |
A..B | что есть в B и нет в A | то же, что A B |
A...B | обе стороны после развилки | merge-base против B: только работа в B |
git help diff прямо оговаривает, что diff сравнивает две конечные точки и точки в нём не
означают диапазон. Для ревью ветки нужен вариант с тремя точками, его показывает и pull request на
GitHub. Длинная форма того же: git diff --merge-base main feature.
1.3Три дерева
Проект в git существует в трёх экземплярах сразу: последний коммит, индекс и файлы на диске.
Почти каждая повседневная команда копирует содержимое из одного экземпляра в другой. Когда
знаешь, откуда и куда, add, commit, diff и
switch перестают удивлять.
- Что лежит в
.git/indexи почему там перечислены все файлы проекта, а не только изменённые. - Почему после
git addи ещё одной правки в коммит уходит старая версия файла. - Какие два снимка сравнивают
git diff,git diff --stagedиgit diff HEAD. - Когда
git switchпереносит незакоммиченные правки на другую ветку, когда отказывается и когда молча затирает файл. - Как перестать отслеживать файл через
git rm --cachedи что после этого случится у коллег.
Три снимка одного проекта
Слово «дерево» в названии главы стоит условно. Речь о трёх полных наборах файлов, и только один из них устроен как объект tree из главы 1.1.
Возьмём маленький Go-проект из трёх файлов и сделаем первый коммит. Теперь каждый файл
существует трижды. Коммит 7a1b9c3 хранит его в своём дереве, на этот коммит
указывает HEAD (как HEAD находит коммит, разобрано в главе 1.2). В файле .git/index
записаны те же пути с хешами блобов: это индекс, он же staging area, черновик
следующего коммита. И сами файлы лежат в каталоге проекта, в рабочей копии (working
tree), где их открывает редактор.
$ git add .
$ git commit -m "Первый коммит"
[main (root-commit) 7a1b9c3] Первый коммит
3 files changed, 13 insertions(+)
create mode 100644 README.md
create mode 100644 go.mod
create mode 100644 main.go
$ git ls-tree -r HEAD # дерево коммита
100644 blob 8954bb97349bfe2a7799e6a7a64c6f747c635d6c README.md
100644 blob 39c5ef80ac37708bfc5a6de808d737b8d9091e19 go.mod
100644 blob 54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6 main.go
$ git ls-files --stage # индекс
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0 README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0 go.mod
100644 54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6 0 main.go
$ git status
On branch main
nothing to commit, working tree clean
Хеши в двух списках совпадают до символа, а working tree clean в ответе
status означает ровно это: все три снимка одинаковы. Стоит поправить файл, и один
снимок разойдётся с остальными. Команды git переносят содержимое из снимка в снимок.
| Где лежит | Что внутри | Как посмотреть | |
|---|---|---|---|
| HEAD | объекты в .git/objects | tree и blob последнего коммита, неизменяемые | git ls-tree -r HEAD, git show HEAD:main.go |
| Индекс | один файл .git/index | плоский список: путь, режим, хеш блоба, данные stat | git ls-files --stage, git show :main.go |
| Рабочая копия | каталог проекта | обычные файлы; git читает их, только когда его попросят | редактор, ls, cat |
Запись :main.go (двоеточие и путь без имени коммита) указывает на версию файла в индексе.
add и ещё одной
правки: у main.go в каждом снимке своя версия. add и commit
переносят содержимое слева направо, switch переписывает индекс и диск из коммита,
а три варианта diff сравнивают снимки попарно.Индекс изнутри
Индекс хранится в двоичном файле, и первые двенадцать байт у него всегда устроены одинаково:
сигнатура DIRC, версия формата, число записей.
$ head -c 12 .git/index | xxd
00000000: 4449 5243 0000 0002 0000 0003 DIRC........
Версия 2, три записи. Сортирует их git по пути побайтно, поэтому README.md
с заглавной буквы стоит раньше go.mod. В каждой записи лежат хеш блоба, режим
(100644 у обычного файла, 100755 у исполняемого), флаги с номером
стадии и ещё пачка полей, которые git взял у системного вызова lstat: время
изменения, inode, владелец, размер. Отладочный режим ls-files их показывает:
$ git ls-files --debug main.go
main.go
ctime: 1789562384:990065496
mtime: 1789562384:990065496
dev: 16777232 ino: 139124911
uid: 501 gid: 20
size: 93 flags: 0
Числа у тебя будут другие. Зачем они вообще нужны? Чтобы не читать файлы. По команде
git status git не хеширует весь проект заново: он зовёт lstat, сверяет
время, размер и inode с записью в индексе и при совпадении считает файл нетронутым, в
содержимое не заглядывая. Проверим на файле, у которого поменялось только время:
$ touch README.md
$ git diff-files # низкоуровневое сравнение индекса с диском
:100644 100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0000000000000000000000000000000000000000 M README.md
$ git status --short
$ git diff-files
diff-files увидел новое время и доложил: файл, возможно, изменён, а хеш версии на
диске неизвестен, отсюда нули. git status перечитал файл, получил прежний блоб и
записал в индекс свежие данные stat, поэтому второй diff-files молчит.
Бывает и наоборот: файл переписали сразу после записи индекса, а время не сдвинулось. В
документации это называется racy git, и такие записи git сверяет по содержимому.
В обычном индексе каталогов нет, только файлы с полными путями вроде cmd/api/main.go.
Объекты tree появляются при коммите, когда плоский список сворачивается в дерево, а готовые
хеши неизменённых каталогов git держит в расширении TREE после записей, чтобы не
пересчитывать их. Поэтому пустой каталог не добавить:
git add tmp молча ничего не сделает.
Третья колонка ls-files --stage, тот самый ноль, это номер стадии. Во время
конфликта у пути появляются три записи со стадиями 1, 2 и 3: общий предок и две стороны
(глава 3.2). У индекса есть и старое имя. В первом коммите git, 7 апреля 2005 года, он назывался
«current directory cache» и лежал в .dircache/index. Отсюда сигнатура
DIRC и флаг --cached у diff и rm: видишь
cached, значит речь об индексе.
add: из рабочей копии в индекс
Поменяем приветствие в main.go с "hello" на
"hello, world". Пока правка живёт только на диске, и короткий статус ставит
M во вторую колонку. Колонок две: первая сравнивает индекс с HEAD, вторая
рабочую копию с индексом (формат подробно разобран в главе 2.1).
$ git status --short
M main.go
$ find .git/objects -type f | wc -l # три блоба, tree и commit
5
$ git add main.go
$ find .git/objects -type f | wc -l
6
$ git ls-files --stage main.go
100644 527077fd1407ddb47597f2cb96bb242e70d3b108 0 main.go
$ git hash-object main.go
527077fd1407ddb47597f2cb96bb242e70d3b108
add сделал две вещи: записал содержимое файла блобом в .git/objects
(объектов стало шесть) и переписал запись main.go в индексе. Коммита ещё нет, а
объект в базе уже есть. Добавишь файл ещё раз в другом виде, и первый блоб останется лежать без
ссылок; как такие блобы находят, рассказано в главе 5.2.
Теперь допишем в main.go константу version, уже без
add:
$ git status
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: main.go
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: main.go
$ git status --short
MM main.go
$ git rev-parse HEAD:main.go :main.go
54c3e58101acc4fa5b6cc0c6f3da35861f4b6ea6
527077fd1407ddb47597f2cb96bb242e70d3b108
Один файл сразу в двух разделах, и противоречия тут нет. status сравнивает
дважды, HEAD с индексом и индекс с диском, а main.go отличается в обоих случаях. В трёх
снимках лежат три разные версии: 54c3e58 в коммите, 527077f в индексе
и третья на диске, для которой блоба пока нет. Именно это состояние нарисовано на схеме.
Три diff: три пары снимков
Снимков три, пар для сравнения тоже три, и на каждую пару своя команда. В состоянии
MM все три показывают разное. git diff без аргументов сравнивает
индекс с рабочей копией, git diff --staged сравнивает HEAD с индексом:
$ git diff
diff --git a/main.go b/main.go
index 527077f..2639041 100644
--- a/main.go
+++ b/main.go
@@ -4,6 +4,8 @@ import "fmt"
const greeting = "hello, world"
+const version = "1.0"
+
func main() {
fmt.Println(greeting)
}
$ git diff --staged
diff --git a/main.go b/main.go
index 54c3e58..527077f 100644
--- a/main.go
+++ b/main.go
@@ -2,7 +2,7 @@ package main
import "fmt"
-const greeting = "hello"
+const greeting = "hello, world"
func main() {
fmt.Println(greeting)
А git diff HEAD перепрыгивает через индекс, сравнивает коммит прямо с диском и
видит обе правки разом, строка index у него 54c3e58..2639041:
$ git diff HEAD --stat
main.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
| Команда | Что с чем | Что показывает |
|---|---|---|
git diff | индекс → рабочая копия | правки, которые ещё не добавлены |
git diff --staged(то же, что --cached) | HEAD → индекс | что уйдёт в коммит при git commit |
git diff HEAD | HEAD → рабочая копия | что уйдёт в коммит при git commit -a |
Посмотри на строку index в каждом заголовке: там короткие хеши двух сравниваемых
версий. 527077f и 54c3e58 лежат в базе, а 2639041 git
посчитал на лету по файлу с диска. Объекта с таким именем нет,
git cat-file -t 2639041 ответит fatal: Not a valid object name 2639041.
Блоб появится после add.
Новый файл, который ни разу не добавляли, не покажет ни одна из трёх команд: ни в индексе, ни
в HEAD его нет, сравнивать не с чем. Создадим handler.go и спросим
git diff HEAD --stat: в ответе будет только main.go. Чтобы увидеть новый
файл в diff, не добавляя его содержимое, есть git add -N (intent to add,
«намерение добавить»). В индекс попадает запись с хешем пустого блоба, и файлу уже есть с чем
сравниваться:
$ git add -N handler.go
$ git ls-files --stage handler.go
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 handler.go
$ git status --short
A handler.go
MM main.go
$ git diff -- handler.go
diff --git a/handler.go b/handler.go
new file mode 100644
index 0000000..f40b8ea
--- /dev/null
+++ b/handler.go
@@ -0,0 +1,3 @@
+package main
+
+func handle() {}
A встала во вторую колонку: по мнению git, сам файл в индекс ещё не добавлен,
там лежит пустышка. Как добавлять файл по частям через add -p, разобрано в главе 2.1.
Дальше handler.go нам не нужен: запись-намерение снимет
git rm --cached handler.go (о ней ниже), а сам файл удалим с диска обычным
rm.
commit: из индекса в историю
Из рабочей копии git commit в коммит ничего не берёт. Он берёт индекс, сворачивает его в
объекты tree, создаёт commit с этим деревом и родителем из HEAD и передвигает ветку, на
которую указывает HEAD. В нашем состоянии MM в коммит уйдёт версия
527077f, а константа version, дописанная после add,
останется на диске:
$ git write-tree # дерево, которое получится из индекса
5fa97ccc233adb75fc4b93178e107ef2677c9012
$ git commit -m "Приветствие целиком"
[main e198e94] Приветствие целиком
1 file changed, 1 insertion(+), 1 deletion(-)
$ git rev-parse "HEAD^{tree}"
5fa97ccc233adb75fc4b93178e107ef2677c9012
$ git status --short
M main.go
Дерево, которое write-tree посчитал из индекса до коммита, совпало с деревом нового
коммита. Можно пойти дальше: в копии репозитория, снятой до коммита, собрать тот же коммит
тремя низкоуровневыми командами. Автор и даты те же, так что хеш сходится до символа:
$ tree=$(git write-tree)
$ commit=$(git commit-tree $tree -p HEAD -m "Приветствие целиком")
$ echo $commit
e198e94e516acb59df47bb33003909d31e74da6d
$ git update-ref refs/heads/main $commit
$ git log --oneline
e198e94 Приветствие целиком
7a1b9c3 Первый коммит
Настоящий git commit вдобавок запускает хуки, без -m открывает
редактор и печатает сводку, но данные переносит именно так. С флагом -a он сначала
делает add для всех отслеживаемых файлов, то есть тех, что уже есть в индексе.
Создадим handler.go заново: в индексе его нет, и commit -a его не
заметит.
$ git status --short
M main.go
?? handler.go
$ git commit -a -m "Версия"
[main c65842e] Версия
1 file changed, 2 insertions(+)
$ git status --short
?? handler.go
switch: из коммита в индекс и рабочую копию
git switch гонит данные в обратную сторону. Для примера возьмём репозиторий
попроще: в main лежит только первый коммит 7a1b9c3, а в ветке
feature поверх него есть коммит, где main.go стал HTTP-сервером и
появился handler.go. Поправим README.md и
переключимся:
$ git log --oneline --all
059dea8 HTTP-сервер
7a1b9c3 Первый коммит
$ git diff --stat main feature
handler.go | 7 +++++++
main.go | 7 +++----
2 files changed, 10 insertions(+), 4 deletions(-)
$ ls -i go.mod main.go
139133129 go.mod
139133620 main.go
$ echo '## Запуск' >> README.md
$ git switch feature
M README.md
Switched to branch 'feature'
$ git ls-files --stage
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0 README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0 go.mod
100644 9de80c73578699b9ddf6c25a48ef89291f9c8cb2 0 handler.go
100644 327bda13f3114bab4a920f7a577123bbd421bc25 0 main.go
$ ls -i go.mod main.go
139133129 go.mod
139139089 main.go
$ git status --short
M README.md
HEAD теперь указывает на feature. В индексе сменились две записи:
main.go и появившийся handler.go. go.mod в обоих
коммитах одинаков, и git его не трогал (inode прежний), а main.go записан заново.
Правка в README.md пережила переключение, о чём говорит строка
M README.md: файл в ветках одинаков, и его оставили как был.
feature. Файлы, разные в двух коммитах, переписываются из целевой ветки, одинаковые
остаются на месте вместе с твоими правками. Если правка лежит в файле, который пришлось бы
переписать, переключения не будет.
Теперь вернёмся в main (правка в README.md переедет обратно тем же
способом) и поменяем ещё и main.go. Этот файл в ветках разный, и деть правку при
переключении некуда: версия из feature затёрла бы её. На это git не идёт и
отказывает, ничего не тронув:
$ git status --short
M README.md
M main.go
$ git switch feature
error: Your local changes to the following files would be overwritten by checkout:
main.go
Please commit your changes or stash them before you switch branches.
Aborting
Выхода четыре: закоммитить, спрятать правку в stash (глава 2.2), переключиться с
--merge или выбросить правки через --discard-changes; все четыре
разобраны в ответе на третий вопрос. Неотслеживаемые файлы защищены тем же правилом: лежит на
диске свой handler.go, а в feature файл с таким путём закоммичен, и
switch остановится с The following untracked working tree files would be overwritten.
На файлы из .gitignore эта защита не действует: git считает их
расходным материалом. Пусть handler.go с черновиком у тебя игнорируется, а в
feature файл с тем же путём закоммичен. После git switch feature на
диске окажется версия из ветки, без ошибки и без предупреждения. Так легко лишиться локального
конфига, который в текущей ветке игнорируется, а в старой ещё лежит в репозитории. С флагом
--no-overwrite-ignore switch вместо перезаписи откажет. В справке switch этого флага
нет, он описан у git checkout, но switch его понимает.
switch и git read-tree -m -u HEAD feature отдают решение одной и той
же функции twoway_merge, слиянию двух деревьев. В документации
read-tree ему посвящена таблица из 22 случаев; выжимка из неё есть в ответе на
третий вопрос. Переключение можно повторить руками: read-tree, затем
git symbolic-ref HEAD refs/heads/feature. Результат тот же, а на правке в
main.go вместо отказа switch получишь
error: Entry 'main.go' not uptodate. Cannot merge.
rm --cached: убрать из индекса, оставить на диске
git rm удаляет файл из индекса и с диска, git rm --cached только из
индекса: файл остаётся в каталоге, но в следующий коммит не попадёт. Типичный случай: в
репозиторий по ошибке попал .env. Одной строки в .gitignore мало: игнор
действует только на файлы, которых нет в индексе (подробнее в главе 2.2):
$ echo ".env" > .gitignore
$ echo "DB_PASSWORD=changed" > .env
$ git status --short # .env уже в .gitignore, а правку всё равно видно
M .env
?? .gitignore
$ git rm --cached .env
rm '.env'
$ git status --short
D .env
?? .gitignore
$ ls -A
.env
.git
.gitignore
go.mod
$ git add .gitignore
$ git commit -m "Не хранить .env в репозитории"
[main 0a99ec9] Не хранить .env в репозитории
2 files changed, 1 insertion(+), 1 deletion(-)
delete mode 100644 .env
create mode 100644 .gitignore
D в первой колонке значит, что удаление уже в индексе. Строки ?? .env
нет: из индекса файл ушёл, и .gitignore наконец на него действует. На диске файл
цел. Для каталога понадобится -r, без него git rm --cached .idea
ответит fatal: not removing '.idea' recursively without -r. Потерять что-то
rm --cached не даст: если версия в индексе отличается и от HEAD, и от диска, он
откажет с staged content different from both the file and the HEAD.
А что получит коллега? Боря склонировал репозиторий до этого коммита, и
.env у него на диске нетронутый:
$ git pull # строки fetch опущены
Updating 5c3e0d5..0a99ec9
Fast-forward
.env | 1 -
.gitignore | 1 +
2 files changed, 1 insertion(+), 1 deletion(-)
delete mode 100644 .env
create mode 100644 .gitignore
$ ls -A
.git
.gitignore
go.mod
$ git show ORIG_HEAD:.env > .env
$ git status --short --ignored
!! .env
В истории rm --cached неотличим от обычного удаления: коммит говорит «файла больше
нет». Подтягивая такой коммит, git приводит индекс и рабочую копию коллеги к новому дереву и
стирает .env с его диска. Были в файле свои правки, и pull откажет с
Your local changes to the following files would be overwritten by merge.
Предупреди команду заранее. Удалённый файл можно достать из коммита до слияния, как в примере выше
через ORIG_HEAD. И помни, что из истории файл никуда не делся:
git show 5c3e0d5:.env выведет пароль любому, у кого есть клон. Что делать с
утёкшим секретом, разобрано в главе 5.2.
В репозитории без единого коммита не сработает git restore --staged: он берёт
версию из HEAD, а ветка, на которую HEAD указывает, ещё не создана, и команда падает с
fatal: could not resolve 'HEAD'. Убрать файл из индекса можно через
git rm --cached или git reset: на пустой ветке он считает HEAD пустым деревом.
git status в этот момент сам подсказывает
use "git rm --cached <file>..." to unstage.
Кто куда копирует
restore попал в таблицу для полноты, его флаги разобраны в главе 2.2.
| Команда | Откуда | Куда | HEAD |
|---|---|---|---|
git add | рабочая копия | индекс, блоб сразу в базу | на месте |
git commit | индекс | новые tree и commit | ветка сдвигается |
git restore | индекс | рабочая копия | на месте |
git restore --staged | HEAD | индекс | на месте |
git switch | коммит ветки | индекс и диск, только различающиеся пути | на другой ветке |
git rm --cached | — | запись удаляется из индекса | на месте |
git reset <коммит> сюда не попал нарочно: он двигает ветку, и его режимы
разобраны в главе 5.1. Форма git reset -- путь ветку не трогает и делает то же, что
git restore --staged.
Вопросы
4.git/index и хранит полный список путей
следующего коммита: режим, хеш блоба, данные stat. Это снимок, а не перечень
изменений. Благодаря ему коммит можно собрать из части правок, status не
перечитывает весь проект, а слияние держит рядом несколько версий конфликтного файла.Что лежит внутри
Двоичный файл: заголовок с сигнатурой DIRC, записи по одной на файл,
отсортированные по пути, расширения и контрольная сумма. Каталогов нет, деревья строятся при
коммите. ls-files --stage показывает все файлы проекта, изменённые и нет:
$ git status --short
M main.go
$ git add main.go
$ git ls-files --stage
100644 8954bb97349bfe2a7799e6a7a64c6f747c635d6c 0 README.md
100644 39c5ef80ac37708bfc5a6de808d737b8d9091e19 0 go.mod
100644 527077fd1407ddb47597f2cb96bb242e70d3b108 0 main.go
README.md и go.mod никто не трогал, а записи на месте. Если бы
индекс хранил только изменения, их бы там не было. Коммит строится из индекса целиком:
git write-tree по этому списку даёт ровно то дерево, которое окажется в коммите.
Три задачи индекса
- Выбор, что коммитить. Поправил пять файлов, а в коммит хочешь положить два? Добавляешь
два, а часть правок внутри файла берёт
git add -p(глава 2.1). Без промежуточного снимка атомарный коммит пришлось бы собирать, откатывая лишнее с диска. - Скорость. Рядом с хешем лежат время изменения, размер и inode.
statusсверяет их сlstatи перечитывает только файлы, у которых что-то не совпало. - Слияние. При конфликте у пути три записи со стадиями 1, 2 и 3: общий предок и обе стороны (глава 3.2).
На чём ошибаются
Считают, что git add помечает файл «для коммита», а в коммит потом уйдёт то, что
лежит на диске. На деле add копирует содержимое в момент запуска. Добавил,
дописал строку, закоммитил: до коммита статус был MM, после коммита
M осталась во второй колонке, и дописанная строка в коммит не попала.
MM в коротком статусе означает, что версия на диске не совпадает с той, что
уйдёт в коммит. Перед коммитом полезно открыть git diff --staged: он
показывает ровно содержимое будущего коммита. Хочешь закоммитить файл в текущем виде,
добавь его ещё раз.
git diff
показывает, чем рабочая копия отличается от индекса, то есть недобавленное. --staged
берёт HEAD и индекс: это будущий коммит. git diff HEAD ставит рядом HEAD и
диск, всё сразу. Пустой git diff значит только одно: диск совпадает с индексом.Три пары
| Команда | Левая сторона | Правая сторона | Эквивалент |
|---|---|---|---|
git diff | индекс | рабочая копия | что добавит git add изменённых файлов |
git diff --staged, --cached | HEAD | индекс | что запишет git commit |
git diff HEAD | HEAD | рабочая копия | что запишет git commit -a |
Когда в файле есть и добавленная, и недобавленная правка, git diff HEAD показывает
их сумму. Приветствие изменили и добавили, константу дописали после: --stat у
трёх команд даёт 2 ++, 2 +- и 4 +++-.
Когда git diff молчит
Первый случай: всё уже добавлено. Индекс совпадает с диском, и сравнивать нечего, а
правки ждут в --staged:
$ git add main.go
$ git diff
$ git diff --staged --stat
main.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git status --short
M main.go
Второй случай: файл новый. Его нет ни в индексе, ни в HEAD, поэтому ни одна из трёх команд
его не покажет, даже git diff HEAD. Виден он только в git status
как ??. Показать новый файл в diff, не добавляя содержимое, можно через
git add -N: в индекс ложится запись с хешем пустого блоба
e69de29, и git diff показывает файл целиком как
new file.
Пустой git diff ничего не говорит о содержимом коммита. В индексе может
лежать забытый отладочный вывод или файл, случайно захваченный git add ..
Перед коммитом смотри git diff --staged: только он показывает то, что будет
записано.
Правило для каждого пути
| Файл в HEAD и в целевом коммите | Локальное состояние | Что сделает switch |
|---|---|---|
| одинаковый | любое | оставит индекс и диск как есть, правка переедет |
| разный | правок нет | возьмёт версию из целевой ветки |
| разный | правка на диске или в индексе | откажет |
| разный | в индексе уже целевая версия | оставит индекс, переключится |
| есть только в целевом | неотслеживаемый файл с тем же путём | откажет |
| есть только в целевом | игнорируемый файл с тем же путём | перезапишет без предупреждения |
Переехавшие правки git перечисляет строками вида M README.md; добавленные в
индекс остаются добавленными.
Неочевидное: сравнивается состояние, а не содержимое
Git не проверяет, пропадёт ли что-то на самом деле. Скопируй в main.go ровно ту
версию, что лежит в feature, и switch всё равно откажет: диск отличается от
индекса. Добавь файл в индекс, и переключение пройдёт, ведь индекс совпал с целевой версией.
Строка M README.md в выводе — несохранённая правка README, она переезжает вместе с
переключением:
$ git show feature:main.go > main.go
$ git switch feature
error: Your local changes to the following files would be overwritten by checkout:
main.go
Please commit your changes or stash them before you switch branches.
Aborting
$ git add main.go
$ git switch feature
M README.md
Switched to branch 'feature'
Что делать, если switch отказал
- Правка закончена: закоммить её в текущей ветке.
- Сырую правку спрячь:
git stash, переключение,git stash popтам, где она нужна (глава 2.2). - Правку надо унести в другую ветку:
git switch -m featureсольёт твою версию с версией ветки. При конфликте файл получит маркеры, статусUU, а в индексе появятся три записи со стадиями 1, 2 и 3; разбор в главе 3.2. - Если правка не нужна,
git switch --discard-changes featureприведёт индекс и диск к целевой ветке.
Индекс и рабочая копия одни на весь репозиторий, ветке принадлежат только коммиты. Начал
правку в main, переключился на feature посмотреть код, и правка
приехала с тобой; закоммитишь не глядя, и она окажется не в той ветке. Если нужны две
ветки сразу, есть git worktree (глава 5.3).
git rm --cached путь, строка в
.gitignore и коммит. У тебя файл остаётся на диске, из следующего дерева он
пропадает. У коллег pull этого коммита удалит файл с диска, а в истории файл
останется навсегда.Последовательность
$ echo ".env" >> .gitignore
$ git rm --cached .env # для каталога: git rm -r --cached .idea
rm '.env'
$ git add .gitignore
$ git commit -m "Не хранить .env в репозитории"
[main 0a99ec9] Не хранить .env в репозитории
2 files changed, 1 insertion(+), 1 deletion(-)
delete mode 100644 .env
create mode 100644 .gitignore
Одного .gitignore мало: на отслеживаемый файл игнор не действует. Вместо
настоящего файла в репозитории держат шаблон, например .env.example, а каждый
копирует его в игнорируемый .env; так советует и gitfaq.
Что произойдёт у коллег
Для истории это обычное удаление. На pull git переводит индекс и диск коллеги на
новое дерево без .env и стирает файл. Если в файле были локальные правки,
pull остановится:
$ git status --short
M .env
$ git pull # строки fetch опущены
Updating 5c3e0d5..0a99ec9
error: Your local changes to the following files would be overwritten by merge:
.env
Please commit your changes or stash them before you merge.
Aborting
Если правок не было, файл пропадёт молча, но вернуть его легко: после pull в
ORIG_HEAD записан коммит, на котором ветка стояла до слияния, и
git show ORIG_HEAD:.env > .env достанет прежнее содержимое. Раз файл теперь в
.gitignore, git его больше не заметит.
Чего делать не надо
Вместо rm --cached иногда советуют git update-index --assume-unchanged
или --skip-worktree, чтобы git перестал замечать правки. Документация
update-index и gitfaq прямо говорят, что для этого флаги не годятся:
часть операций всё равно сверяет файл с индексом, а когда файл поменяется в чужом коммите,
слияние на нём споткнётся. Способа «отслеживать, но не замечать правки» в git нет.
Коммит с удалением ложится поверх старых, а прежние коммиты остаются как были:
git show 5c3e0d5:.env выведет DB_PASSWORD=local любому, у кого
есть клон. Если в файле был настоящий пароль или токен, считай его утёкшим. Как
переписывать историю и почему без смены ключа это не спасает, разобрано в главе 5.2.
1.4Хранение и клоны
В модели git каждая версия файла хранится полным объектом. На диске так лежат только свежие объекты: время от времени git собирает их в packfile, где похожие объекты записаны дельтами друг от друга. Отсюда понятно, почему удалённая ветка не освобождает место, чем shallow-клон отличается от partial и почему закоммиченный бинарник остаётся в репозитории навсегда.
- как объект лежит в
.git/objectsдо упаковки и после и чем дельта в пачке отличается от диффа; - что запускают
git gcи автоматическое обслуживание и что поменялось в git 2.54; - когда недостижимый объект действительно исчезает и что его до этого держит;
- что отрезают
--depth,--filterи sparse-checkout и где каждый из них подводит; - почему бинарник нельзя убрать из репозитория новым коммитом.
Отдельный объект: файл, сжатый zlib
В главе 1.1 объект получал имя по хешу содержимого вместе с заголовком blob <size>\0.
На диск он попадает сразу, ещё при git add: первые два знака хеша становятся каталогом
в .git/objects, остальные 38 дают имя файла. Такой объект называют loose object,
отдельным объектом: один объект, один файл.
Возьмём живой файл, net/http/server.go из исходников Go 1.27.1 (4292 строки,
139 414 байт), и закоммитим его в пустой репозиторий:
$ cp "$(go env GOROOT)/src/net/http/server.go" .
$ git add server.go && git commit -q -m "Добавить server.go"
$ find .git/objects -type f | sort
.git/objects/16/58854e5145a61966765c300b483814461daa06
.git/objects/6e/2dcb9efc557234c650acc43c736d44124f43ff
.git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
$ python3 -c 'import sys, zlib; print(zlib.decompress(open(sys.argv[1], "rb").read())[:40])' .git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
b'blob 139414\x00// Copyright 2009 The Go Aut'
Коммит оставил три файла: blob, tree и commit. Распакованный zlib-ом blob начинается с того самого
заголовка из главы 1.1. Сжимает git здесь с уровнем 1, самым быстрым (core.looseCompression):
отдельные объекты пишутся на каждый add и commit, и скорость тут важнее места.
Поменяем в файле две строки, по одной на коммит: TimeFormat и DefaultMaxHeaderBytes.
$ git diff --stat HEAD~2
server.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
$ git rev-parse HEAD~2:server.go HEAD~1:server.go HEAD:server.go
aff62f9a8dc595e7baaa266d3cbded0946ff8506
e7f15ca956c41e675452d4c7dc5d787af039378a
92a35610b7f32ec13ca40e3183cbb6da485dcbc4
$ wc -c .git/objects/af/f62f9a* .git/objects/e7/f15ca9* .git/objects/92/a35610*
52913 .git/objects/af/f62f9a8dc595e7baaa266d3cbded0946ff8506
52915 .git/objects/e7/f15ca956c41e675452d4c7dc5d787af039378a
52915 .git/objects/92/a35610b7f32ec13ca40e3183cbb6da485dcbc4
158743 total
$ git count-objects -v
count: 9
size: 180
in-pack: 0
packs: 0
size-pack: 0
prune-packable: 0
garbage: 0
size-garbage: 0
Изменились две строки из 4292, а на диске три почти одинаковых файла по 52,9 КБ. Отдельный
объект ничего не знает о прошлых версиях: это снимок, сжатый сам по себе. count-objects -v
показывает число таких файлов и место под ними в КиБ, с округлением до блоков файловой системы.
Нули в in-pack и size-pack значат, что пачек пока нет.
Packfile: объекты в одном файле и индекс к нему
История настоящего проекта в таком виде превратилась бы в сотни тысяч мелких файлов. Поэтому git
время от времени собирает объекты в packfile, пачку, где они идут подряд. Вручную это делает
git gc:
$ git gc -q
$ find .git/objects -type f | sort
.git/objects/info/commit-graph
.git/objects/info/packs
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.idx
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.pack
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.rev
$ git count-objects -v | grep pack
in-pack: 9
packs: 1
size-pack: 44
prune-packable: 0
В .pack лежат сами объекты: у каждого короткий заголовок с типом и размером, дальше
сжатые данные. По .idx объект находят, не читая пачку: в нём отсортированные хеши
и смещение каждого объекта. Перед списком стоит таблица на 256 ячеек, где в ячейке N записано,
сколько хешей начинается с байта не больше N, так что git по первому байту сразу получает узкий
диапазон и ищет в нём двоичным поиском. .rev отвечает на обратный вопрос, какой объект
лежит по смещению; с git 2.41 он пишется по умолчанию.
Было 180 КиБ, стало 44. Клон получает объекты с сервера сразу пачкой, и отдельных объектов в
свежем клоне нет совсем. В полном клоне prometheus (ветка main на 16 сентября 2026 года)
156 835 объектов, и вот сколько они занимают в разном виде:
| Объекты prometheus | Объём |
|---|---|
| Содержимое без сжатия | 2,98 ГиБ |
Отдельными файлами (git unpack-objects) | 947 МиБ, на диске 1,36 ГиБ из-за блоков |
| Одной пачкой с индексом | 287,5 МиБ |
Пачка выигрывает на сжатии посильнее (уровень 6 вместо 1) и на дельтах: из 65 622 блобов prometheus дельтами записаны 49 744.
Дельты: между похожими объектами, а не между коммитами
Заглянем внутрь пачки:
$ git verify-pack -v .git/objects/pack/pack-*.idx
d565fbbe7a1e04de1bee9c05d60fe49bffebb64a commit 235 181 12
6361bfd6c8adb0ba6dd9a89ed2be3c98b605cb0f commit 226 178 193
1658854e5145a61966765c300b483814461daa06 commit 177 144 371
92a35610b7f32ec13ca40e3183cbb6da485dcbc4 blob 139414 43684 515
bed668ac0f78de195c147dc4e3b931c0fce6d313 tree 37 48 44199
df9d70b971e30e1c775d4806306703d64184b653 tree 37 48 44247
e7f15ca956c41e675452d4c7dc5d787af039378a blob 31 45 44295 1 92a35610b7f32ec13ca40e3183cbb6da485dcbc4
6e2dcb9efc557234c650acc43c736d44124f43ff tree 37 48 44340
aff62f9a8dc595e7baaa266d3cbded0946ff8506 blob 21 33 44388 2 e7f15ca956c41e675452d4c7dc5d787af039378a
non delta: 7 objects
chain length = 1: 1 object
chain length = 2: 1 object
.git/objects/pack/pack-820e9ee31b89dcbdac928bd980506241d8761a0e.pack: ok
Колонки: хеш, тип, размер, размер в пачке, смещение, а у двух блобов ещё глубина цепочки и хеш базы.
Последняя версия server.go (92a3561) лежит целиком, 43 684 байта.
Предыдущая (e7f15ca) записана дельтой: это 31 байт инструкций, как собрать её
из базового объекта. Самая старая (aff62f9) хранится дельтой от предыдущей, на глубине 2. Чтобы
её прочитать, git распакует 92a3561 и применит обе дельты по очереди. Глубина цепочки
ограничена 50 (pack.depth).
С патчем из git diff у дельты общего мало. В ней инструкции двух видов: «скопировать из
базы столько-то байт с такого-то смещения» и «вставить эти байты», строк она не знает. А
git diff, log -p и show дельт не касаются: они сравнивают два
снимка целиком в момент вызова.
И базу упаковка выбирает без оглядки на историю. git pack-objects сортирует объекты по
типу, хешу имени файла и размеру и для каждого пробует в роли базы соседей по списку, в окне из 10
объектов (pack.window). К чему это приводит, видно по двум опытам:
| Опыт | Целиком | Дельтой |
|---|---|---|
Во второй версии server.go оставили 2000 строк из 4292 | старая, большая версия | новая: 9 байт |
api/server.go и admin/server_copy.go в одном коммите отличаются строкой package | admin/server_copy.go | api/server.go: 23 байта |
Порядок «последняя версия целиком, старые дельтами» держится на сортировке: сначала по размеру, а при равном размере новые версии идут раньше старых. Код чаще растёт, чем худеет. Стоит файлу уменьшиться, и целиком останется старая версия, а базой может оказаться вообще другой файл.
gc, repack и maintenance: кто упаковывает и когда
git gc запускает целый набор команд, и GIT_TRACE=1 их показывает (служебные
pack-objects отфильтрованы):
$ GIT_TRACE=1 git gc 2>&1 | grep -o 'run_command: .*' | grep -v pack-objects
run_command: git pack-refs --all --prune
run_command: git reflog expire --all
run_command: git repack -d -l --cruft --cruft-expiration=2.weeks.ago
run_command: git prune --expire 2.weeks.ago
run_command: git worktree prune --expire 3.months.ago
run_command: git rerere gc
pack-refs складывает ссылки в packed-refs (глава 1.2), reflog expire
чистит reflog, repack --cruft собирает достижимое в одну пачку, а недостижимое в cruft-пачку (о ней ниже),
prune стирает отдельные недостижимые объекты старше двух недель. Только пачки, без
остальной уборки, пересобирает git repack -a -d, а с -f он заново подбирает дельты. git gc --aggressive
берёт окно 250 вместо 10, и документация советует не запускать его без замеров: времени уходит намного
больше, а выигрыш может не стоить того.
Руками gc почти никто не зовёт. После commit, merge,
rebase, am и fetch git сам запускает обслуживание:
$ GIT_TRACE=1 git commit -q -m 'Добавить doc.go' 2>&1 | grep -o 'run_command: .*'
run_command: git maintenance run --auto --quiet --detach
--auto значит «проверь пороги и выйди, если делать нечего», а пороги зависят от версии.
С git 2.29, где появился git maintenance, по 2.53 по умолчанию работала задача
gc: она срабатывала, когда отдельных объектов больше 6700 (gc.auto) или пачек
больше 50 (gc.autoPackLimit). В git 2.54 её сменила стратегия geometric.
Всё с нуля она не пересобирает: сливает пачки так, чтобы каждая следующая по размеру содержала хотя бы
вдвое больше объектов, чем предыдущая, и отдельные объекты упаковывает уже от 100
(maintenance.geometric-repack.auto).
Git не пересчитывает все отдельные объекты после каждого коммита. Он считает файлы в одном каталоге,
.git/objects/17, и умножает на 256, а порог округляет вверх до кратного 256. В git 2.54
на тысяче отдельных объектов в 17/ нашёлся один файл, оценка вышла 256, и git maintenance is-needed --auto ответил, что обслуживание
не нужно. На двух тысячах объектов файлов там было пять, и пачка собралась.
Когда недостижимое удаляется на самом деле
Объект достижим, если до него можно дойти по ссылкам: от веток, тегов, remote-tracking веток,
refs/stash, записей reflog и индекса. Недостижимое git удаляет не сразу. Проверим:
заведём ветку, закоммитим в неё архив исходников net/http на 708 КиБ и удалим её.
$ git switch -q -c experiment
$ mkdir testdata && cp ../http.tar.gz testdata/
$ git add testdata && git commit -q -m "Положить архив в testdata"
$ git rev-parse --short HEAD:testdata/http.tar.gz
4756bea
$ git switch -q main && git branch -D experiment
Deleted branch experiment (was 7d86a1f).
$ git gc -q
$ git cat-file -t 4756bea
blob
$ git count-objects -vH | grep size-pack
size-pack: 753.32 KiB
$ git reflog
d565fbb HEAD@{0}: checkout: moving from experiment to main
7d86a1f HEAD@{1}: commit: Положить архив в testdata
d565fbb HEAD@{2}: checkout: moving from main to experiment
d565fbb HEAD@{3}: commit: Поднять DefaultMaxHeaderBytes
6361bfd HEAD@{4}: commit: Поменять TimeFormat
1658854 HEAD@{5}: commit (initial): Добавить server.go
Ветки нет, а архив пережил gc. Держит его reflog HEAD с записью о коммите
7d86a1f: удаление ветки стирает её журнал, но не журнал HEAD. Документация обещает
записям reflog 90 дней (gc.reflogExpire), а записям о коммитах, недостижимых из текущей
вершины, 30 дней (gc.reflogExpireUnreachable). В git 2.54 без явных настроек и те и
другие стираются через 30 дней: умолчания в коде стоят наоборот. Нужны 90 дней — задай оба срока в
конфиге. Промотаем эти 30 дней вручную (про reflog подробно в главе 5.2):
$ git reflog expire --expire-unreachable=now --all
$ git gc -q
$ git cat-file -t 4756bea
blob
$ ls .git/objects/pack/*.mtimes
.git/objects/pack/pack-549aa0f36791d522afbf7c36bd101d136c11b4c4.mtimes
$ git gc -q --prune=now
$ git cat-file -t 4756bea
fatal: Not a valid object name 4756bea
$ git count-objects -vH | grep size-pack
size-pack: 44.69 KiB
Ссылок на архив уже нет, но gc снова его оставил, переложив недостижимые объекты в
cruft-пачку, пачку для мусора. Рядом с ней лежит .mtimes со временем изменения
каждого объекта, и объект удалит тот gc, который застанет его старше двух недель
(gc.pruneExpire). Cruft-пачки появились в git 2.37 и включены по умолчанию с 2.41; до
этого недостижимое выкладывалось из пачки отдельными файлами и ждало тех же двух недель.
Отсрочка защищает от гонки: параллельный commit или fetch может уже записать
объект, но ещё не поставить на него ссылку. --prune=now защиту снимает, и документация
git gc предупреждает, что при параллельной записи так можно испортить репозиторий. Если
коммит выпал из всех веток, сначала истекает запись в reflog, потом отсрочка до двух недель,
и место освобождает следующий gc. На сервере и в клонах коллег объекты живут по своим правилам
(глава 5.2).
Клоны: shallow, partial и sparse-checkout
Полный клон приносит все объекты, достижимые с сервера. Взять меньше можно тремя способами, и режут
они разное. Shallow clone, мелкий клон (--depth), обрезает историю: коммиты глубже
границы не приходят. Partial clone, частичный (--filter), приносит всю историю, но
часть объектов оставляет на сервере и докачивает по требованию. Sparse-checkout решает, какие
каталоги попадут в рабочую копию, и сам по себе сеть не трогает.
Prometheus, ветка main, коммит 50461b6, 16 сентября 2026 года:
| Клон | Объектов | Пачки | Коммитов в истории | Файлов на диске |
|---|---|---|---|---|
git clone URL | 156 835 | 287,5 МиБ | 18 642 | 1687 |
--depth 1 | 1936 | 6,2 МиБ | 1 | 1687 |
--filter=blob:none | 92 887 | 28,4 МиБ | 18 642 | 1687 |
--filter=tree:0 | 23 359 | 17,0 МиБ | 18 642 | 1687 |
--filter=blob:none --sparse | 91 242 | 22,3 МиБ | 18 642 | 28 |
то же после sparse-checkout set tsdb | 91 409 | 25,3 МиБ | 18 642 | 196 |
Коммиты посчитаны git rev-list --count HEAD. --depth подразумевает
--single-branch, остальные клоны взяли все ветки.
Shallow. Граница истории записана в .git/shallow: для этих коммитов git считает,
что родителей нет. Вот копия репозитория из начала главы с тегами v0.1.0 и v0.2.0:
$ git clone -q --depth 1 file://$PWD/origin shallow && cd shallow
$ git log --oneline --decorate
d565fbb (grafted, HEAD -> main, origin/main, origin/HEAD) Поднять DefaultMaxHeaderBytes
$ cat .git/shallow
d565fbbe7a1e04de1bee9c05d60fe49bffebb64a
$ git describe --tags
fatal: No names found, cannot describe anything.
$ git blame -L 1,1 server.go
^d565fbb (Аня 2026-09-03 10:00:00 +0300 1) // Copyright 2009 The Go Authors. All rights reserved.
$ git fetch -q --unshallow
$ git describe --tags
v0.2.0-1-gd565fbb
grafted значит, что у коммита отрезаны родители. describe не видит тегов, их
коммиты не пришли, а blame приписывает строку из первого коммита граничному (знак
^). Если ветки разошлись глубже границы, git merge-base молча вернёт код 1,
пример в вопросе 3. Историю догружает git fetch --unshallow или --deepen=N.
Локальный путь без file:// git клонирует копированием и --depth игнорирует.
actions/checkout по умолчанию забирает один коммит, GitLab в новых проектах клонирует на
глубину 20. Сборке и тестам хватает, а шаг с git describe, changelog с прошлого тега или
поиском файлов, изменённых относительно main, падает. Ему нужны fetch-depth: 0
в GitHub Actions, 0 в настройке Git shallow clone проекта GitLab или git fetch --unshallow.
Partial. С --filter=blob:none сервер присылает коммиты и деревья без блобов, а
блобы рабочей копии git докачивает при checkout. Сервер становится promisor remote: git считает,
что любой недостающий объект можно у него попросить.
$ git -C origin config uploadpack.allowFilter true
$ git clone -q --filter=blob:none file://$PWD/origin partial && cd partial
$ git config --get-regexp "^remote\.origin\.(promisor|partialclonefilter)"
remote.origin.promisor true
remote.origin.partialclonefilter blob:none
$ git rev-list --objects --all --missing=print | grep "^?"
?e7f15ca956c41e675452d4c7dc5d787af039378a
?aff62f9a8dc595e7baaa266d3cbded0946ff8506
$ GIT_TRACE=1 git log -p --oneline 2>&1 | grep -c 'built-in: git fetch'
2
Первая команда нужна только локальному источнику, иначе git пишет
filtering not recognized by server, ignoring и клонирует всё; GitHub и GitLab фильтры
поддерживают. Двух старых версий server.go в клоне не было, и git log -p
дважды сходил за ними на сервер.
Эти походы и есть цена partial clone. git diff между двумя коммитами просит нужные блобы
одним запросом, а log -p и blame по одному. В клоне prometheus с
blob:none команда git log -p -10 -- go.sum обратилась к серверу 10 раз, а
git blame README.md 145 раз и шла почти две минуты (время зависит от сети).
--filter=tree:0 не присылает и деревьев, и там даже git log --oneline -3 -- go.mod
сделал 44 запроса за 35 секунд. --filter=blob:limit=1m оставляет на сервере только блобы
от мегабайта.
Sparse-checkout. В режиме cone, включённом по умолчанию, перечисляются каталоги, а файлы из
корня репозитория на диске есть всегда. git clone --sparse начинает с одного корня:
$ git clone -q --filter=blob:none --sparse https://github.com/prometheus/prometheus sparse2 && cd sparse2
$ find . -path ./.git -prune -o -type f -print | wc -l
28
$ git sparse-checkout set tsdb
$ find . -path ./.git -prune -o -type f -print | wc -l
196
$ git ls-files -t | cut -c1 | sort | uniq -c
196 H
1491 S
Остальные 1491 файл остались в индексе с пометкой skip-worktree (буква S): git о них знает,
но на диск не пишет. С partial clone это сочетается хорошо: set tsdb докачал блобы только
этого каталога, 167 объектов одной пачкой.
$ git sparse-checkout set cmd
$ go build ./cmd/api
cmd/api/main.go:6:2: no required module provides package example.com/shop/internal/store; to add it:
go get example.com/shop/internal/store
$ git sparse-checkout add internal/store
$ go build ./cmd/api
Пакет internal/store лежит в том же модуле, просто его каталога нет на диске. Подсказка
про go get уводит в сторону, чинит это git sparse-checkout add.
Почему бинарник раздувает репозиторий навсегда
Частая ошибка: сервис на Go собрали и закоммитили вместе с бинарником server, потом дважды
поправили код (ответ в JSON, логирование через slog), каждый раз пересобирая и коммитя:
$ go build -o server . && git add . && git commit -q -m "Сервер и собранный бинарник"
$ go build -o server . && git commit -q -am "Отвечать JSON"
$ go build -o server . && git commit -q -am "Логировать запросы"
$ git gc -q
$ git verify-pack -v .git/objects/pack/pack-*.idx | grep blob
39c5ef80ac37708bfc5a6de808d737b8d9091e19 blob 34 44 532
82b333c2c99870257d2cd5ac1dbe30d729c74ff8 blob 309 252 576
c494755436fb972c6f4d79c0a6dc968caf8277aa blob 9365330 5279746 828
a92c77efd7fefa3ae3d1704727981e7d68a44eaf blob 12 25 5280796 1 82b333c2c99870257d2cd5ac1dbe30d729c74ff8
682a90155d19debeb8af46e1c8c8fed68a51705f blob 9241506 5207136 5280821
ea6fbdc8783d1790ce309472848a596262bf2343 blob 48 62 10488067 1 82b333c2c99870257d2cd5ac1dbe30d729c74ff8
4c04ce5c62953429498f728d716cc06cfa2f6a64 blob 8200578 4631593 10488129
$ git count-objects -vH | grep size-pack
size-pack: 14.42 MiB
$ git rm -q --cached server && echo server > .gitignore
$ git add .gitignore && git commit -q -m "Убрать бинарник из репозитория"
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 14.42 MiB
$ cd .. && git clone -q --no-local bin bin-clone && cd bin-clone
$ ls
go.mod
main.go
$ git count-objects -vH | grep size-pack
size-pack: 14.42 MiB
Сборки весят 7,8 МиБ, 8,8 МиБ и 8,9 МиБ. Zlib ужал каждую почти вдвое, а выгодной
дельты между ними упаковка не нашла, и все три лежат целиком. Сам main.go хранится одной
версией на 309 байт и двумя дельтами, в 12 и 48 байт.
Коммит «Убрать бинарник» ничего не освободил. Он добавил дерево без server, но старые
коммиты по-прежнему указывают на свои деревья, а те на блобы бинарника. Хеш коммита зависит от хеша
дерева (глава 1.1), поэтому прошлый коммит не поменять, не поменяв все следующие. Новый клон получает
всю достижимую историю: бинарника в рабочей копии нет, а пачка всё те же 14,42 МиБ.
Со сжатыми форматами ещё хуже. Архив исходников net/http в .tar.gz
(708 КиБ) после правки одной строки упаковка сохранила второй раз целиком, и две версии заняли
1,38 МиБ. Те же исходники файлами после такой же правки подросли на 0,4 КиБ. А файлы крупнее
core.bigFileThreshold (512 МиБ) git пакует, даже не пытаясь искать дельту.
Поэтому артефакты сборки держат в .gitignore. Неотправленный коммит с бинарником
достаточно переписать локально (вопрос 4), а отправленный убирают, только переписав историю
у всех (глава 5.2). Большие файлы, которые правда нужны, хранят в Git LFS: в git коммитится маленький
файл-указатель, а содержимое лежит на отдельном сервере и скачивается, когда версия попадает в рабочую
копию (глава 5.3).
Вопросы
4git log -p считается на лету.Модель
Поменял строку, и появились новый блоб на весь файл, новые деревья по пути к нему и новый коммит, а всё неизменённое коммит берёт по старым хешам. Checkout старого коммита не проигрывает патчи от начала истории: git читает дерево коммита и раскладывает его блобы.
Хранение
Отдельными файлами три версии server.go, отличающиеся двумя строками, заняли по
52,9 КБ. В пачке одна осталась целиком (43 684 байта), две стали дельтами в 45 и 33 байта.
У prometheus это 2,98 ГиБ содержимого против 287,5 МиБ пачки.
Чем дельта не похожа на дифф
- Дельта работает с байтами: «скопировать отрезок из базы» и «вставить байты», строк в ней нет.
- Базу выбирает не родительский коммит, а сортировка по типу, имени и размеру в окне из 10 объектов. Целиком обычно лежит самая большая версия, и дельта может ссылаться на другой файл.
- Дифф в
log -p,showиdiffстроится из двух снимков в момент вызова и от упаковки не зависит.
«Git хранит снимки. Внутри packfile их сжимают дельтами, подобранными эвристикой по похожести
объектов, а diff и checkout о дельтах не знают.»
gc переносит объекты в
cruft-пачку и удаляет, когда они старше двух недель. Сразу удаляют reflog expire и
gc --prune=now, но так пропадает шанс вернуть работу.Кто держит объект
Кроме веток это теги, remote-tracking ветки вроде origin/feature, refs/stash,
индекс и reflog. Кто держит, покажет git fsck: без флагов он считает reflog источником
ссылок, с --no-reflogs нет. Удалённая ветка с архивом из раздела выше:
$ git fsck --unreachable
$ git fsck --unreachable --no-reflogs
unreachable blob 4756bea960ec3501e2e437caba8087c227c66657
unreachable tree 3073216cd491edb16c27eaec3402a57941681acb
unreachable tree 5a4d0626aa7808c039b17e084544c3403386e31b
unreachable commit 7d86a1f80bdb3caa09b1aeb26c4c2f0a48f8e513
$ git branch -a --contains 7d86a1f
$ git reflog | grep 7d86a1f
7d86a1f HEAD@{1}: commit: Положить архив в testdata
Пустой первый вывод и четыре объекта во втором значат, что коммит и архив держит только reflog.
Сроки
| Что | Срок | Настройка |
|---|---|---|
| Запись reflog о коммите, достижимом из вершины | 90 дней по документации, в 2.54 на деле 30 | gc.reflogExpire |
Запись reflog о недостижимом коммите (после reset, --amend, удаления ветки) | 30 дней | gc.reflogExpireUnreachable |
| Недостижимый объект, отдельный или в cruft-пачке | 2 недели | gc.pruneExpire |
Сроки проверяет gc. Автоматическое обслуживание в git 2.54 работает по стратегии
geometric и до них может не дойти долго: reflog оно чистит, только когда накопится сотня
устаревших записей, а недостижимые объекты удаляет, только когда сводит все пачки в одну. Место
надёжно освобождает ручной git gc после того, как истекут оба срока.
Как удалить сейчас
git reflog expire --expire-unreachable=now --all, затем git gc --prune=now:
в том же опыте пачка ужалась с 753 до 45 КиБ. Но после такой чистки reflog уже не вернёт
потерянный коммит (глава 5.2), а --prune=now снимает защиту от гонки, так что запускай
его, когда с репозиторием никто больше не работает.
Если ветку успели отправить, её объекты остаются на сервере и в клонах коллег, и твой
gc на них не влияет. Для утёкшего секрета удалить ветку мало, разбор в главе 5.2.
--depth 1, пока шагам не нужна история. Для работы в большом репозитории удобнее
--filter=blob:none, при нужде вместе со sparse-checkout.Что режет каждый способ
| Способ | Чего нет локально | Что ломается или тормозит | Как вернуть |
|---|---|---|---|
--depth N | коммитов глубже N и их объектов | describe, merge-base, blame, changelog | fetch --unshallow, --deepen=N |
--filter=blob:none | блобов вне рабочей копии | log -p и blame ходят за каждой версией | докачивается само |
--filter=tree:0 | деревьев и блобов | даже log -- путь | само, медленно |
| sparse-checkout | файлов вне выбранных каталогов | go build не видит их пакеты | sparse-checkout add |
CI
Сборке и тестам нужен один снимок, и --depth 1 на prometheus даёт 6,2 МиБ вместо
287,5. Но стоит шагу заглянуть в историю, и одного коммита мало. Здесь ветка feature
отошла от main два коммита назад:
$ git clone -q --depth 1 --no-single-branch file://$PWD/mb-origin mb-shallow && cd mb-shallow
$ git merge-base origin/main origin/feature; echo "exit $?"
exit 1
$ git diff --name-only origin/main...origin/feature; echo "exit $?"
fatal: origin/main...origin/feature: no merge base
exit 128
$ git fetch -q --unshallow
$ git diff --name-only origin/main...origin/feature
f.txt
Таким шагам нужна полная история. Если важен объём, берут --filter=blob:none: коммиты и
деревья на месте, а git diff --name-only на двухстах коммитах prometheus обошёлся одним
запросом за блобами.
Большой репозиторий
Для ежедневной работы удобен --filter=blob:none: git log по коммитам и путям
работает без сети, старые версии файлов приходят, когда понадобятся. В монорепо к нему добавляют
sparse-checkout. Treeless-клон для работы не годится, там git log --oneline -3 -- go.mod
сделал 44 запроса. А если blame нужен постоянно, вспомни про 145 запросов на один
README.md: полный клон обойдётся дешевле по нервам.
git gc.
Отправленный можно убрать, только переписав историю у всех (глава 5.2), а на будущее такие файлы
хранят в LFS (глава 5.3).Почему новый коммит не помогает
Старые коммиты по-прежнему достижимы из ветки вместе со своими деревьями и блобами, а поменять их нельзя: хеш коммита вычисляется из содержимого. В опыте из раздела выше пачка после «Убрать бинарник» весила те же 14,42 МиБ, как в свежем клоне.
Коммит ещё не отправлен
$ git show --stat --oneline HEAD
706e02b HTTP-сервер
main.go | 14 +++++++++++++-
server | Bin 0 -> 9365330 bytes
2 files changed, 13 insertions(+), 1 deletion(-)
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 5.04 MiB
$ git rm -q --cached server && echo server > .gitignore && git add .gitignore
$ git commit -q --amend --no-edit
$ git gc -q && git count-objects -vH | grep size-pack
size-pack: 5.04 MiB
$ git reflog expire --expire-unreachable=now --all && git gc -q --prune=now
$ git count-objects -vH | grep size-pack
size-pack: 2.12 KiB
--amend заменил коммит новым, без бинарника, но старый 706e02b
остался в reflog, и первый gc ничего не освободил. Это не страшно: на сервер бинарник не
попал, а с диска его уберут сроки из вопроса 2. Две последние команды нужны, только если место нужно
прямо сейчас. Если бинарник глубже последнего коммита, а ветка не отправлена, ту же правку делают
интерактивным rebase (глава 3.3).
Коммит уже на сервере
Файл уже есть у всех, кто сделал fetch. Убрать его можно, только переписав коммиты начиная
с того, что его добавил: git filter-repo, принудительный push, и каждый участник
выбрасывает старую историю. Хеши меняются, ветки коллег придётся переносить. Подвохи, включая копии на
сервере, разобраны в главе 5.2.
Артефакты сборки в .gitignore, проверка размера в хуке или на сервере, большие файлы в
Git LFS. На лимиты GitHub не надейся: он предупреждает о файлах от 50 МиБ и не принимает файлы
больше 100 МиБ, а до этого репозиторий успевает распухнуть.