Git: карта трека и модель данных вместо заучивания команд
Есть два способа работать с Git. Первый: держать в закладках шпаргалку из двадцати команд, применять их по образцу и звать коллегу, когда терминал печатает что-то незнакомое. Второй: понимать, что именно Git хранит на диске, — и выводить нужную команду из этого понимания, включая те, которые вы никогда раньше не набирали.
Разница огромна, и она не про стаж. Человек может пять лет коммитить каждый день и оставаться в первом лагере: git pull, git add ., git commit -m, git push покрывают 90% дней. Проблема в оставшихся 10%, где случается «сделал rebase и всё пропало», «пуш отклонён, но я же ничего не ломал» и «слил ветку, а изменений коллеги нет». Там шпаргалка бесполезна: нужного заклинания в ней нет, а понять, какое подойдёт, нечем.
Хорошая новость: модель данных Git крошечная. Четыре типа объектов, одно контентно-адресуемое хранилище, ориентированный ациклический граф и несколько текстовых файлов с указателями. Сложность живёт в командном интерфейсе, а не в модели, и она наносная. Когда модель становится очевидной, reset --hard перестаёт быть страшным заклинанием и превращается в «переставить ссылку и синхронизировать два дерева». Эта статья даёт модель целиком, показывает, как из неё выводятся ежедневные команды, и раскладывает остальные десять материалов трека. Если вы совсем новичок, сначала прочитайте обзорную «Git» и практическую «Терминал и Git» — здесь мы почти сразу лезем под капот.
1. Почему Git кажется сложным, хотя он простой
Git написан Линусом Торвальдсом в апреле 2005 года за две недели, после того как сообщество ядра Linux лишилось бесплатной лицензии на BitKeeper. Первый коммит самого Git датирован 7 апреля 2005 года, а его сообщение звучит так: «Initial revision of “git”, the information manager from hell». Собственная man-страница до сих пор описывает проект как «the stupid content tracker» — «тупой трекер содержимого». Это не самоирония, а точная архитектурная формулировка: слой хранения намеренно глуп и знает только про байты и хеши, всё умное живёт этажом выше.
Отсюда и путаница. Проект строился как набор инструментов, а не как продукт. Нижний слой — «водопровод» (plumbing): hash-object, cat-file, write-tree, commit-tree, update-ref; стабильные команды с машинным выводом. Верхний слой — «фарфор» (porcelain): add, commit, merge, pull, log; он рос органически, годами, разными людьми, с оглядкой на обратную совместимость. В результате одно слово значит разное в разных местах, а одна команда делает три несвязанные вещи.
| Что видит пользователь | Что происходит на самом деле |
|---|---|
git checkout то переключает ветку, то откатывает файл |
Одно имя для двух операций; в Git 2.23 их разделили на git switch и git restore |
git reset бывает «мягкий», «смешанный» и «жёсткий» |
Одна операция «переставь ссылку» с опциональной синхронизацией индекса и рабочего каталога |
HEAD~1 и HEAD^1 выглядят синонимами |
~ идёт вверх по первому родителю, ^ выбирает номер родителя у коммита слияния |
git pull иногда «ломает» ветку |
Это fetch (безопасно) плюс merge/rebase (меняет вашу ветку) |
Джулия Эванс собрала коллекцию таких ловушек в Confusing git terminology — полезно, чтобы убедиться, что дело не в вас. Вывод простой: учить надо не команды, а модель. Команд около 150, они непоследовательны и меняются. Модель одна, она не менялась с 2005 года и умещается на одной схеме.
2. Git — это key-value хранилище, адресуемое содержимым
Всё, что Git когда-либо сохраняет, попадает в объектную базу — хранилище «ключ → значение» в каталоге .git/objects. Уникальность в том, откуда берётся ключ: он не выдаётся счётчиком, а вычисляется из самого значения.
$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'hello\n' | sha1sum # обычный SHA-1 даёт другое число
f572d396fae9206628714fb2ce00f72e94f2258f
$ printf 'blob 6\0hello\n' | sha1sum # Git хеширует содержимое С ЗАГОЛОВКОМ
ce013625030ba8dba906f756967f9e9ca394464a
Заголовок <тип> <размер>\0 нужен, чтобы blob и, скажем, дерево той же длины никогда не столкнулись в одном пространстве имён. Правило записывается несколькими строчками — и это буквально всё, что нужно, чтобы вычислять идентификаторы объектов Git:
"""Идентификатор объекта Git и чтение loose-объекта с диска.
Сложность: O(n) по времени и памяти, где n — размер объекта."""
import hashlib
import zlib
from pathlib import Path
def object_id(obj_type: bytes, content: bytes) -> str:
header = obj_type + b" " + str(len(content)).encode() + b"\x00" # b'blob 6\x00'
return hashlib.sha1(header + content).hexdigest()
def read_loose(repo: Path, oid: str) -> tuple[bytes, bytes]:
path = repo / ".git" / "objects" / oid[:2] / oid[2:] # первые два символа — каталог
head, _, body = zlib.decompress(path.read_bytes()).partition(b"\x00")
return head.split(b" ")[0], body # (тип, содержимое)
assert object_id(b"blob", b"hello\n") == "ce013625030ba8dba906f756967f9e9ca394464a"
assert object_id(b"tree", b"") == "4b825dc642cb6eb9a060e54bf8d69288fbee4904" # пустое дерево
Из «ключ вычисляется из значения» немедленно следуют три свойства, на которых держится весь Git.
- Объекты неизменяемы. Другое содержимое — другой хеш, то есть другой объект. Поэтому в Git нет операции «поправить коммит»:
git commit --amendсоздаёт новый коммит и переставляет ветку на него, старый остаётся на диске. - Дедупликация бесплатна. Два одинаковых файла в разных каталогах — один объект. Тысяча коммитов, не тронувших
LICENSE, — один объект на все тысячу. - Целостность проверяется арифметикой. Достаточно пересчитать хеш и сравнить с именем;
git fsckделает это для всей базы, так что битый диск, оборванная сеть и злонамеренная подмена ловятся одинаково. Хеш коммита включает хеш дерева и хеши родителей, поэтому подмена одного старого коммита меняет идентификаторы всех последующих — тот же приём, что в деревьях Меркла, только на четырнадцать лет раньше моды на них.
Про SHA-1 и «он же сломан». В феврале 2017 года Google и CWI показали практическую коллизию SHA-1 — два разных PDF с одинаковым хешем (shattered.io). Для Git это менее страшно, чем звучит. Атака требует обоих документов на входе, а не подгона под существующий хеш. С версии 2.13 Git считает не чистый SHA-1, а SHA-1 с детектором коллизий (sha1collisiondetection), который распознаёт характерные для атаки блоки и отказывается работать. Плюс идёт переход на SHA-256: git init --object-format=sha256, план описан в Hash Function Transition — пока экспериментально, без совместимости с SHA-1-репозиториями. О свойствах криптографических хешей — в «Прикладной криптографии».
3. Четыре типа объектов — и больше ничего
В хранилище живут ровно четыре типа значений. Посмотрим на них в настоящем репозитории: все хеши ниже воспроизводимы, если задать те же дату, имя и почту автора.
$ export GIT_AUTHOR_DATE="2026-01-15T10:00:00+00:00" GIT_COMMITTER_DATE="2026-01-15T10:00:00+00:00"
$ printf 'hello\n' > README.md && mkdir src && printf 'def main():\n print("hi")\n' > src/app.py
$ git add . && git commit -q -m "Первый коммит" && git rev-parse HEAD
8026e456334b9a4527b2c81f94f884d7d9710677
$ git cat-file -p HEAD # коммит — это обычный текст
tree 446575ea98de8f3139a1b40462c45eb436b32337
author Demo Author <demo@example.com> 1768471200 +0000
committer Demo Author <demo@example.com> 1768471200 +0000
Первый коммит
$ git cat-file -p HEAD^{tree} # корневое дерево
100644 blob ce013625030ba8dba906f756967f9e9ca394464a README.md
040000 tree bfad020b5391f877ec56378ccd185550aef2ecd1 src
$ git cat-file -p HEAD:src # вложенное дерево, хеш src взят из строки выше
100644 blob eff65336fbf40733335106fa64e02036c3970178 app.py
| Тип | Что хранит | Чего НЕ хранит |
|---|---|---|
| blob | сырые байты файла | имя файла, права, путь, дату |
| tree | список записей «режим + имя + хеш» — каталог | абсолютный путь, историю |
| commit | хеш корневого дерева, хеши родителей, автора, коммиттера, сообщение | diff, имя ветки, список изменённых файлов |
| tag (аннотированный) | хеш объекта, имя, автора, сообщение, подпись | ничего сверх этого |
Три наблюдения, которые ломают привычную интуицию. Имя файла хранится не в файле, а в дереве — blob не знает, как он называется, поэтому переименование это не операция, а следствие: новое дерево с новым именем и тем же хешем blob. Коммит не хранит diff, он хранит указатель на полный снимок; diff вычисляется на лету при git show, git log -p, git diff сравнением двух деревьев. Режим — это не права Unix: Git знает четыре значения — 100644 (обычный файл), 100755 (исполняемый), 120000 (симлинк), 040000 (дерево), всё остальное определяет umask при выгрузке.
Заметьте, чего в схеме нет: объект не знает, кто на него ссылается, все стрелки идут в прошлое. Это единственная причина, по которой удаление ветки не удаляет коммиты, а git log не умеет показывать «будущее» коммита. Соберём всё вместе:
К этой схеме стоит возвращаться, пока она не станет очевидной: наверху три «дерева», между которыми ходят ежедневные команды; посередине ссылки — единственная изменяемая часть репозитория; внизу объектная база, куда всё пишется и откуда ничего не пропадает. Ни одна команда Git не делает ничего, кроме перемещения по этим стрелкам.
4. Снимки, а не патчи — и почему это не разоряет диск
Классические системы (CVS, Subversion) хранят историю как последовательность дельт. Git хранит полные снимки каждой версии дерева. На первый взгляд расточительно: тысяча коммитов в репозитории на гигабайт означала бы терабайт. На практике репозиторий ядра Linux с двумя миллионами коммитов клонируется в 5–6 ГБ. Почему?
Первый механизм — дедупликация. Скопируем файл и посмотрим на индекс и новое дерево:
$ mkdir docs && cp README.md docs/intro.md && git add docs/intro.md
$ git ls-files -s # содержимое индекса: путь → режим → хеш
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 README.md
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 docs/intro.md
$ git commit -q -m "Копия README в docs" && git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a README.md
040000 tree 3f3c3a9c842f19e95dc37a249104cbd1835cd39e docs
040000 tree bfad020b5391f877ec56378ccd185550aef2ecd1 src
Два файла с одинаковым содержимым — один blob ce01362. Подкаталог src не менялся, поэтому в новом корневом дереве стоит старый хеш bfad020: целое поддерево переиспользовано без копирования. Второй коммит добавил в базу три объекта — новый blob не понадобился вовсе. Это ровно идея персистентных структур данных: новая «версия» разделяет с прежней все неизменённые узлы, копируется только путь от корня до изменения («Персистентные структуры данных»).
Второй механизм — packfiles. Отдельные объекты (loose objects) удобны для записи, но неэффективны: каждый занимает минимум блок файловой системы. Периодически (git gc, git maintenance, автоматически при push и fetch) Git пакует их в .git/objects/pack/*.pack, где похожие объекты хранятся как дельты друг от друга — и вот здесь дельты наконец появляются. Ключевой момент: дельты это деталь хранения, а не модели. Модель всегда говорит «полный снимок», packfile — способ уложить снимки компактно.
| Операция | Стоимость | Что за ней стоит |
|---|---|---|
| Найти объект по хешу | O(1) для loose, O(log n) в packfile |
путь objects/ab/cdef… или бинарный поиск в .idx |
git commit |
O(k · d) |
k изменённых путей, d глубина дерева — не размер репозитория |
git diff двух версий файла |
до O(n · m) |
алгоритм Майерса, вариация поиска наибольшей общей подпоследовательности |
Последняя строка — прямая связь с «Динамическим программированием»: diff в Git это задача LCS, решённая алгоритмом Юджина Майерса 1986 года с эвристиками отсечения для больших файлов.
5. История — это граф, а не список
Коммит хранит хеши родителей, во множественном числе. Ноль родителей — корневой коммит, один — обычный, два и больше — слияние. Значит, история Git это направленный ациклический граф (DAG), а линейный git log — лишь один из способов его обойти.
Тот же граф в настоящем репозитории:
$ git log --oneline --graph --all
* 4dc7403 C: параллельная правка в main
| * 565a6d9 F2: доработка фичи
| * 22679d8 F1: новая фича
|/
* a69e90b B: правка в main
* f1108f6 A: базовый файл
$ git merge-base main feature # ближайший общий предок
a69e90b18b1a1b577152c2a89b6ba9ac7e935699
$ git rev-list --left-right --count main...feature
1 2 # 1 коммит только в main, 2 только в feature
Граф объясняет синтаксис выборки, который иначе приходится зубрить:
| Запись | Что значит | Когда нужна |
|---|---|---|
main..feature |
достижимые из feature, но не из main |
«что нового в моей ветке» |
main...feature |
симметрическая разность обоих множеств | «чем ветки разошлись» |
HEAD~3 |
три шага вверх по первому родителю | «три коммита назад» |
HEAD^2 |
второй родитель коммита слияния | «что именно влили» |
Ветка в этой модели — не сущность, а имя одного коммита. Буквально:
$ cat .git/refs/heads/main
8026e456334b9a4527b2c81f94f884d7d9710677 # 40 символов и перевод строки
$ cat .git/HEAD
ref: refs/heads/main # HEAD указывает не на коммит, а на ветку
HEAD обычно — символическая ссылка, и из этого выводится всё поведение, которое кажется магией. git commit создаёт объект и переставляет ту ветку, на которую смотрит HEAD. git switch меняет содержимое .git/HEAD. Detached HEAD — состояние, когда в .git/HEAD лежит хеш, а не имя ветки: коммиты создаются нормально, но ни одна ветка за ними не следует, поэтому после переключения они станут недостижимыми — и, важно, не исчезнут (см. раздел про reflog). Про обходы графов и топологическую сортировку — «Обход графов».
6. Три дерева: почему reset кажется тремя разными командами
Между вами и историей стоят три состояния одного набора файлов: рабочий каталог, индекс и коммит, на который смотрит HEAD. Почти каждая ежедневная команда — синхронизация какой-то пары из них.
git status — отчёт о двух сравнениях сразу, и его вывод читается однозначно, если помнить, о каких парах речь:
$ git status
Changes to be committed: # ← разница между HEAD и индексом
modified: README.md
Changes not staged for commit: # ← разница между индексом и рабочим каталогом
modified: src/app.py
Untracked files: # ← нет ни там, ни там
notes.txt
Короткая форма git status -s печатает те же два сравнения двумя колонками: M — индекс против HEAD, M — рабочий каталог против индекса, ?? — неотслеживаемый файл. То же у git diff: без аргументов он показывает рабочий каталог против индекса, с --cached — индекс против HEAD. Поэтому «я сделал git add, а git diff ничего не показывает» — не баг, а ровно то, что написано в модели.
А теперь git reset, который перестаёт быть тремя командами:
| Форма | Ссылка (ветка) | Индекс | Рабочий каталог |
|---|---|---|---|
git reset --soft <c> |
переставить | не трогать | не трогать |
git reset --mixed <c> (по умолчанию) |
переставить | привести к <c> |
не трогать |
git reset --hard <c> |
переставить | привести к <c> |
привести к <c> |
git reset <c> -- <файл> |
не трогать | взять файл из <c> |
не трогать |
Одна операция с тремя уровнями глубины. Единственная действительно опасная строка — третья, и опасна она не для истории (коммиты никуда не денутся), а для незакоммиченных изменений: их нет ни в одном объекте, поэтому восстанавливать нечего.
Индекс, кстати, не абстракция, а бинарный файл .git/index с описанным форматом: список путей, режимов, хешей и закешированных результатов stat — именно этот кеш позволяет git status в репозитории на сто тысяч файлов отрабатывать за секунду, а не за минуту (и он же объясняет, зачем в монорепо включают fsmonitor). Детальный разбор ежедневного цикла, stash, частичного add -p и всех способов отменить изменения — в «Ежедневной работе».
7. Распределённость: три пространства имён ссылок
Слово «распределённая» означает конкретную вещь: у вас полная копия базы объектов, и никакая команда не спрашивает сервер по умолчанию. Обмен происходит только в fetch, push и pull (композиция первого с merge или rebase). Ключ к пониманию — что ссылок три вида и живут они в разных пространствах имён: refs/heads/* — ваши локальные ветки, их двигаете вы коммитом, слиянием, ребейзом и ресетом; refs/remotes/origin/* — зеркало того, что вы в последний раз видели на сервере, его двигает только fetch и руками не трогают; refs/heads/* на сервере — то, что видят все остальные, их двигает push.
Отсюда половина недоразумений про «отстал/опередил». Строчка Your branch is behind 'origin/main' by 3 commits — результат сравнения двух локальных ссылок; никакого запроса к серверу при git status не происходит. Не делали fetch сутки — зеркало устарело, и цифра врёт в обе стороны.
Отказ non-fast-forward — не каприз, а защита инварианта: сервер двигает ссылку только вперёд по графу. Если это невозможно, значит, кто-то опубликовал коммиты, которых у вас нет, и слепой --force их сотрёт. Правильный ответ — git push --force-with-lease: он передаёт серверу ожидаемое старое значение ссылки, и сервер откажет, если ссылка успела сдвинуться (с Git 2.30 есть строже: --force-if-includes дополнительно проверяет, что вы действительно видели чужие коммиты). Практический вывод: git fetch можно звать сколько угодно и когда угодно — он не трогает ни ваши ветки, ни рабочий каталог; опасна вторая половина git pull, поэтому многие команды ставят git config --global pull.ff only и делают слияние явным шагом. Теория про расхождение реплик — в «Моделях согласованности».
8. Reflog — суперсила, о которой узнают слишком поздно
Самое частое заблуждение: «я сделал reset --hard и потерял два дня работы». Почти всегда это неправда. Git ведёт журнал перемещений каждой ссылки — reflog: каждый раз, когда HEAD или любая ветка меняет значение, в .git/logs/ дописывается строка «откуда, куда, какой командой». Смоделируем катастрофу и восстановление целиком:
$ git log --oneline | head -2
a5216ac F2: доработка фичи
9122626 F1: новая фича
$ git reset --hard HEAD~2 # «ой»
$ git log --oneline | head -1
4dc7403 C: параллельная правка в main
$ ls
a.txt c.txt # f.txt исчез из рабочего каталога
$ git reflog -n 4
4dc7403 HEAD@{0}: reset: moving to HEAD~2
a5216ac HEAD@{1}: checkout: moving from feature to feature
a5216ac HEAD@{2}: rebase (finish): returning to refs/heads/feature
a5216ac HEAD@{3}: rebase (pick): F2: доработка фичи
$ git reset --hard 'HEAD@{1}' # вернуть ветку туда, где она была
$ git log --oneline | head -1
a5216ac F2: доработка фичи # и f.txt снова на диске
Ничего не восстанавливалось из бэкапа: коммит a5216ac всё время лежал в объектной базе, он просто перестал быть достижимым из какой-либо ветки. Что стоит запомнить про reflog прямо сейчас, не дожидаясь статьи про восстановление:
- Reflog локальный и персональный: не клонируется, не пушится, не приходит с сервера. У коллеги своя история перемещений.
- У каждой ссылки свой журнал:
git reflogпоказываетHEAD,git reflog show main— веткуmain; синтаксисmain@{2}значит «где была ветка два перемещения назад», аmain@{2.days.ago}— «где она была позавчера». - Записи не вечны:
gc.reflogExpire= 90 дней для достижимых коммитов,gc.reflogExpireUnreachable= 30 дней для остальных. После этогоgit gcможет удалить объекты физически. - Если ветка удалена и её журнал пропал, остаётся
git fsck --unreachable --no-reflogs: он перечислит все висячие объекты (unreachable commit 9122626…,unreachable tree 026c2a4…), и дальше вы просто вешаете на нужный коммит новую ветку.
Правило, которое стоит выучить наизусть: если изменения хоть раз попали в коммит или в индекс, они почти наверняка живы. Безвозвратно теряется ровно три вещи: правки в рабочем каталоге, никогда не проходившие через git add (их затирают checkout, reset --hard, stash drop); файлы, которых не было в индексе, после git clean -fd; объекты после того, как истёк срок reflog и отработал git gc --prune. Отсюда совет, экономящий нервы: перед любой рискованной операцией делайте git add -A или git stash -u — всё, что попало в индекс, обрело blob в базе и стало восстановимым.
9. Rebase против merge: честный разбор
Самый долгоиграющий спор в командах — и он почти никогда не про Git. Начнём с механики, она короткая и не оставляет места для мнений.
Merge создаёт один новый коммит с двумя родителями; все исходные коммиты остаются на месте с теми же хешами.
$ git merge --no-ff feature -m "Merge branch 'feature'"
$ git log --oneline --graph
* d919494 Merge branch 'feature'
|\
| * 565a6d9 F2: доработка фичи
| * 22679d8 F1: новая фича
* | 4dc7403 C: параллельная правка в main
|/
* a69e90b B: правка в main
$ git cat-file -p HEAD | head -3
tree 6a362aef549a11325e1130484463c17652b80d61
parent 4dc740326908bed84861567a0b1f41bda2ee40d2 # ← два родителя — это
parent 565a6d9b26d52711982f762a6212b89b8c336344 # и есть весь merge
Rebase ничего не переносит — он создаёт копии коммитов с другим родителем, а значит, с другими хешами. Оригиналы остаются в базе, но становятся недостижимыми:
$ git checkout feature && git rebase main
$ git log --oneline --graph --all
* a5216ac F2: доработка фичи # ← новый хеш вместо 565a6d9
* 9122626 F1: новая фича # ← новый хеш вместо 22679d8
* 4dc7403 C: параллельная правка в main
$ git cat-file -t 22679d8 # старый F1 никуда не делся
commit
Вот и всё различие на уровне модели. Дальше начинается инженерия.
| Критерий | Merge | Rebase |
|---|---|---|
| Хеши существующих коммитов | сохраняются | меняются (создаются копии) |
| Форма истории | граф с развилками | прямая линия |
| Фиксирует «когда влили» | да, отдельным коммитом | нет |
| Конфликты | один раз, на весь набор изменений | потенциально на каждом коммите |
| Опубликованная ветка | безопасно | ломает работу тех, кто на неё ссылается |
| Атомарный откат фичи | git revert -m 1 <merge> |
несколько revert подряд |
Золотое правило «не ребейзите то, что уже опубликовано» — прямое следствие модели, а не суеверие: если коллега основал ветку на ваших коммитах, а вы заменили их копиями, его pull увидит две параллельные истории с одинаковыми изменениями и устроит конфликты на ровном месте.
кто-то вытянул?"} B -->|да| M["Только merge
или новый коммит поверх"] B -->|нет| C{"История ветки
осмысленна по шагам?"} C -->|"да, коммиты атомарны"| D{"Команда читает
историю линейно?"} C -->|"нет, десяток fixup и wip"| S["Squash: один коммит
с внятным сообщением"] D -->|да| R["Rebase на свежий main,
затем fast-forward"] D -->|нет| N["Merge --no-ff:
видно границы фичи"]
А теперь главное. Спор «rebase или merge» — это спор о процессе, а не о Git. За техническим вопросом почти всегда стоит один из трёх нетехнических:
- Насколько велики ветки. При ветках на два дня и десять файлов разница между стратегиями незаметна. При ветках на три недели больно будет в любом случае, и обсуждать надо не команду, а размер задач.
- Что считается «историей». Одни считают её журналом того, как всё происходило (тогда merge честнее). Другие — документацией того, как проект пришёл в текущее состояние (тогда линейная история читабельнее). Оба взгляда законны, но несовместимы, и команда должна выбрать один.
- Кто платит за конфликты и что умеет хостинг. Rebase перекладывает разрешение на автора ветки, до ревью; merge откладывает его до интеграции — это распределение работы между людьми, а не техническая характеристика. А GitHub, GitLab и Bitbucket предлагают три кнопки (merge commit, squash, rebase-and-merge), и выбранная кнопка обычно решает спор за команду.
Компромисс, который в 2020-х работает у большинства: короткие ветки; rebase своей неопубликованной ветки на свежий main перед ревью; squash при вливании, если история ветки состоит из «fixup» и «wip»; обычный merge-commit, если коммиты внутри ветки осмысленны. Механика обеих операций, трёхстороннее слияние и стратегии ort/resolve — в «Merge и rebase»; всё про переписывание уже сделанного — в «Переписывании истории».
10. Проверка модели: «странности», которые перестают быть странными
Хороший критерий понимания — способность объяснить поведение, а не запомнить его.
| Наблюдение | Объяснение из модели |
|---|---|
| Пустой каталог не попадает в коммит | Дерево хранит записи только о файлах и подкаталогах; для пустого каталога записи нет. Отсюда трюк с .gitkeep |
После --amend «изменились» все последующие коммиты |
Хеш родителя входит в хеш коммита: изменили один — изменились все потомки |
| Переименование не хранится | В дереве появилась новая запись с тем же blob; R100 в git show --raw — эвристика поиска похожих файлов, включённая по умолчанию с Git 2.9 |
.gitignore не действует на уже закоммиченный файл |
Игнорируются только неотслеживаемые пути; нужен git rm --cached |
git log -- файл иногда «теряет» куски истории |
По умолчанию история упрощается: коммиты слияния, не менявшие файл, пропускаются. Помогают --full-history и --follow |
| Коммиты в detached HEAD «пропадают» | Их не держит ни одна ветка: они недостижимы, но живы — до истечения reflog |
Если каждый пункт читается как очевидный, модель усвоена и дальше трек пойдёт легко.
11. Карта трека
| Статья | Главный вопрос | Что унесёте |
|---|---|---|
| 01. Внутри Git | что физически лежит в .git |
чтение объектов руками, packfiles, устройство индекса |
| 02. Ежедневная работа | как перестать бояться отменять | add -p, stash, restore, reset, revert |
| 03. Стратегии ветвления | trunk-based, GitFlow или GitHub Flow | выбор под частоту релизов и размер команды |
| 04. Merge и rebase | как это работает под капотом | трёхстороннее слияние, стратегия ort, когда что |
| 05. Переписывание истории | как чинить прошлое и не навредить | amend, rebase -i, filter-repo, удаление секретов |
| 06. Конфликты | почему возникают и как их меньше | маркеры, rerere, diff3, архитектурная профилактика |
| 07. Восстановление | что делать, когда всё пропало | reflog, fsck, битые объекты, спасение из packfile |
| 08. Хуки и автоматизация | как навязать правила без надзирателя | pre-commit, серверные хуки, подпись коммитов |
| 09. Монорепо | что делать, когда репозиторий вырос | submodules, subtree, sparse-checkout, LFS, partial clone |
| 10. Совместная работа | как это масштабируется на людей | ревью в масштабе, соглашения о коммитах, релизы и теги |
Два маршрута. Если времени мало — 01 → 02 → 04 → 07: модель, ежедневный цикл, интеграция, спасение; этого достаточно, чтобы уверенно работать и выбираться из большинства неприятностей. Если строите процесс в команде — добавьте 03, 08 и 10; там же стыковка с треками DevOps и «Принципы». Из уже опубликованного на портале сюда примыкают обзорная «Git» (зачем вообще нужна система контроля версий) и «Pull Request» (как оформить запрос на изменения, чтобы его быстро приняли) — этот трек их не повторяет, а продолжает.
12. Упражнение на тридцать минут: собрать коммит руками
Лучший способ убедиться, что модель именно такая, — сделать коммит без единой porcelain-команды. Каждая строка ниже соответствует одному шагу схемы из раздела 3.
$ git init -q -b main handmade && cd handmade
$ export GIT_AUTHOR_NAME="Demo Author" GIT_AUTHOR_EMAIL="demo@example.com" \
GIT_COMMITTER_NAME="Demo Author" GIT_COMMITTER_EMAIL="demo@example.com" \
GIT_AUTHOR_DATE="2026-01-15T10:00:00+00:00" GIT_COMMITTER_DATE="2026-01-15T10:00:00+00:00"
# 1. Положить содержимое в объектную базу — файла на диске при этом нет вообще
$ b=$(printf 'hello\n' | git hash-object -w --stdin) && echo $b
ce013625030ba8dba906f756967f9e9ca394464a
# 2. Записать в индекс путь, режим и хеш; 3. превратить плоский индекс в дерево
$ git update-index --add --cacheinfo 100644,$b,README.md
$ t=$(git write-tree) && echo $t
853694aae8816094a0d875fee7ea26278dbf5d0f
# 4. Обернуть дерево в коммит (родителя нет — он корневой); 5. переставить ветку
$ c=$(git commit-tree $t -m "Коммит, собранный вручную") && echo $c
bfa03de4082b7257d830a2386e623cc519af15a4
$ git update-ref refs/heads/main $c
$ git log --oneline && git status -s
bfa03de Коммит, собранный вручную
D README.md # ← в истории файл есть, а на диске его нет
$ git checkout . && cat README.md # материализовать рабочий каталог из HEAD
hello
Обратите внимание на предпоследний шаг: полноценная история существует, хотя README.md ни разу не лежал в рабочем каталоге. Рабочий каталог — производная величина, а не источник истины. Проверьте напоследок командой git cat-file --batch-check --batch-all-objects, что в базе ровно три объекта — tree 37, commit 208, blob 6 байт. Если после этого упражнения git status перестал выглядеть загадочно, цель статьи достигнута.
Мини-итог
- Git — это key-value хранилище, где ключ вычисляется из значения, плюс DAG над ним, плюс текстовые файлы со ссылками. Всё остальное — интерфейс.
- Четыре типа объектов: blob, tree, commit, tag. Объекты неизменяемы, поэтому любая «правка» — создание нового объекта.
- Имя файла живёт в дереве, а не в blob; diff вычисляется на лету; дельты существуют только внутри packfile. Ветка — файл с одним хешем,
HEAD— обычно символическая ссылка на неё; ссылки — единственная изменяемая часть репозитория. - Три дерева (рабочий каталог, индекс, HEAD) объясняют
status,diff,add,resetиrestoreкак синхронизацию пар, аfetchбезопасен всегда: он трогает только зеркалоrefs/remotes/*, опасна вторая половинаpull. - Reflog хранит перемещения ссылок 30–90 дней; почти всё «пропавшее» восстанавливается за две команды.
- Rebase создаёт копии коммитов, merge создаёт коммит с двумя родителями. Выбор между ними — решение о процессе команды, а не о Git.
Источники
- Scott Chacon, Ben Straub. Pro Git, 2nd ed. — https://git-scm.com/book/en/v2, особенно глава 10 «Git Internals»
- Технические документы Git — https://github.com/git/git/tree/master/Documentation/technical, включая pack-format и index-format
gitcore-tutorial— введение в plumbing от авторов Git: https://git-scm.com/docs/gitcore-tutorial, словарь терминов gitglossary- John Wiegley. Git From the Bottom Up — https://jwiegley.github.io/git-from-the-bottom-up/
- Thibault Polge. Write Yourself a Git! — https://wyag.thb.lt/ — реализация Git на Python с нуля
- Julia Evans. Confusing git terminology — https://jvns.ca/blog/2023/11/01/confusing-git-terminology/ и Inside .git — https://jvns.ca/blog/2024/01/26/inside-git/
- SHAttered: первая практическая коллизия SHA-1 — https://shattered.io/, переход на SHA-256 — https://git-scm.com/docs/hash-function-transition
Что дальше
Внутри Git: объекты, blob, tree, commit, ссылки и DAG истории — разбираем .git по каталогам, читаем объекты руками, смотрим, как устроены packfile и индекс, и доводим модель данных до состояния, в котором её можно реализовать самому.