Git и командная работа Git: карта трека и модель данных вместо заучивания команд
0%

Git: карта трека и модель данных вместо заучивания команд

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 на одной схеме

К этой схеме стоит возвращаться, пока она не станет очевидной: наверху три «дерева», между которыми ходят ежедневные команды; посередине ссылки — единственная изменяемая часть репозитория; внизу объектная база, куда всё пишется и откуда ничего не пропадает. Ни одна команда 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 увидит две параллельные истории с одинаковыми изменениями и устроит конфликты на ровном месте.

А теперь главное. Спор «rebase или merge» — это спор о процессе, а не о Git. За техническим вопросом почти всегда стоит один из трёх нетехнических:

  1. Насколько велики ветки. При ветках на два дня и десять файлов разница между стратегиями незаметна. При ветках на три недели больно будет в любом случае, и обсуждать надо не команду, а размер задач.
  2. Что считается «историей». Одни считают её журналом того, как всё происходило (тогда merge честнее). Другие — документацией того, как проект пришёл в текущее состояние (тогда линейная история читабельнее). Оба взгляда законны, но несовместимы, и команда должна выбрать один.
  3. Кто платит за конфликты и что умеет хостинг. 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.

Источники

Что дальше

Внутри Git: объекты, blob, tree, commit, ссылки и DAG истории — разбираем .git по каталогам, читаем объекты руками, смотрим, как устроены packfile и индекс, и доводим модель данных до состояния, в котором её можно реализовать самому.

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

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

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

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