Перейти к содержимому

Ветвление: указатели, HEAD, switch и чтение истории

Ветвление — та фича, радиоторой Git вообще существует. Ирония в том, что под капотом там почти ничего нет: ветка — это текстовый файл размером 41 байт со строкой хэша, а переключение веток — это перемещение одного указателя и перезапись файлов в рабочей директории (вся механика веток — Pro Git, глава 3 «Ветвление в Git»). Когда ты это видишь, страх перед «сложными» операциями вроде rebase и detached HEAD исчезает: перед тобой просто указатели и объекты.

В продакшене ветки — это конвейер разработки: feature-ветки, ветки релизов, hotfix-ветки. Ты каждый день создаёшь, переключаешь и удаляешь их. А ещё ты каждый день читаешь историю: кто когда что влил, откуда растёт ветка, где расходились ветки и слились обратно. Умение читать git log --graph и понимать, что HEAD — это просто файл, превращает хаос git-графа в понятную картину.

Разбираем механику указателей, HEAD во всех состояниях, разницу между switch, restore и старым checkout, управление ветками и чтение истории.

Из главы про internals ты знаешь: refs/heads/main — текстовый файл с 40 hex-символами и переводом строки. Давай добьём картину до конца:

.git/
├── HEAD # "ref: refs/heads/main" ← символическая ссылка
└── refs/
└── heads/
├── main # "9c2d5a8f..." ← 41 байт: хэш коммита
└── feature # "71bf2c1d..."

Создать ветку — значит создать файл. Удалить ветку — удалить файл. Сравни два способа:

Окно терминала
git branch feature # porcelain
echo "71bf2c1d..." > .git/refs/heads/feature # руками, то же самое

Что происходит при git commit в ветку feature:

ДО коммита: ПОСЛЕ коммита:
refs/heads/feature ──▶ C refs/heads/feature ──▶ C' (новый!)
refs/heads/main ──▶ C refs/heads/main ──▶ C (на месте)
HEAD = "ref: refs/heads/feature" HEAD = "ref: refs/heads/feature"
C' = commit с parent=C
(parent ──▶ C, C не тронут)

Ключевой вывод: коммит не «принадлежит» ветке — ветка просто указывает на коммит. Коммит знает своего родителя, но не знает, какие ветки на него смотрят. Ветка — закладка, которую можно передвигать куда угодно (git reset, см. главу про merge/rebase). Это инверсия ментальной модели «ветка — это линия коммитов»: на самом деле линия коммитов — это просто цепочка parent-указателей, а ветка — подвижная метка на одном из них.

A ←──── B ←──── C ←──── D ← цепочка parent-указателей
▲ ▲
│ │
refs/heads/ refs/heads/
main feature

Ветка feature «указывает» на D, но «содержит» A→B→C→D, потому что каждый коммит тянет за собой всю цепочку родителей. Отсюда же следует, почему ветки в Git дешёвые: создание ветки — это запись 41 байта на диск, никакого копирования файлов.

HEAD — файл .git/HEAD. У него два формата:

Формат 1 — attached HEAD (символическая ссылка на ветку):

.git/HEAD: "ref: refs/heads/feature"
HEAD ──▶ refs/heads/feature ──▶ D
git commit: создаётся E, файл feature перезаписывается
на хэш E, HEAD не меняется

Формат 2 — detached HEAD (прямой хэш):

.git/HEAD: "71bf2c1d..." (сам хэш, без "ref:")
HEAD ──▶ C (ни одна ветка не указывает на C)
git commit: создаётся C', HEAD перезаписывается на C',
НО ни одна ветка не двигается!
C' достижим только через HEAD и reflog

Проверить, в каком состоянии ты, проще всего так:

Окно терминала
git status
# "On branch feature" ← attached
# "HEAD detached at 71bf2c1" ← detached

Detached HEAD возникает в трёх ситуациях: git checkout <хэш коммита> (или тег), git checkout origin/main (remote-ref — не ветка!), редкие сценарии вроде git rebase на середине.

Диагностика и схема состояния:

A ←──── B ←──── C ←──── D ←──── E
▲ ▲
│ │
(тег v1.0) refs/heads/main
git checkout v1.0 (или git checkout B)
HEAD ──▶ B ← прямой хэш, "detached"
refs/heads/main ──▶ E ← main на месте, работаешь «мимо» неё

Это не ошибка и не поломка — Git просто честно говорит: «ты не на ветке, новые коммиты ни к какой ветке не привяжутся». Опасность одна: сделаешь коммиты в detached HEAD, переключишься на ветку — и новые коммиты останутся без имени. Через reflog они видны неделями, но без имени их легко забыть и потерять при gc (спасательные рецепты — Oh Shit, Git!?!).

Что делать:

Окно терминала
# Вариант 1: просто смотрел — вернись на ветку
git switch main # или: git switch -
# Вариант 2: случайно сделал коммиты — привяжи их к ветке
git switch -c my-fix # создать ветку прямо на текущем HEAD
git switch main
# Вариант 3: уже ушёл, коммиты потерялись — reflog спасёт
git reflog # найди хэш потерянного коммита
git switch -c rescued <хэш>

Исторически git checkout делал всё сразу: переключал ветки, восстанавливал файлы из index и из коммитов, создавал ветки с -b. Это приводило к неприятным сюрпризам: git checkout файл молча перезаписывал твои правки. В Git 2.23 (2019) команду разделили на две с явными именами (git-switch(1) и git-restore(1)):

Задача Современная команда Старый аналог
Переключиться на ветку git switch feature git checkout feature
Создать и переключиться git switch -c feature git checkout -b feature
Восстановить файл из index git restore файл git checkout -- файл
Восстановить файл из HEAD git restore --source=HEAD файл git checkout HEAD -- файл
Убрать файл из index git restore --staged файл git reset HEAD файл
Окно терминала
git switch main # только ветки: безопасно
git restore --staged app.ts # убрать из index (unstage), файл не трогать
git restore app.ts # перезаписать файл из index (потеря правок!)
git restore --source=HEAD~1 app.ts # перезаписать из конкретного коммита

Правило: switch — для перемещения между ветками, restore — для возврата файлов. Старый checkout ты всё равно встретишь в туториалах, старом коде и на серверах со старым Git — знать его обязательно, но в своём коде пиши явные команды.

Управление ветками: создать, удалить, переименовать

Заголовок раздела «Управление ветками: создать, удалить, переименовать»
Окно терминала
git switch -c feature/login # создать от текущего HEAD и переключиться
git branch feature/login # создать, остаться где был
git branch -m old-name new-name # переименовать текущую ветку
git branch -m new-name # переименовать, находясь в ней
git branch -d feature/login # удалить (безопасно: только если влита)
git branch -D feature/login # принудительно удалить (есть невлитые коммиты)
git branch --list 'feat/*' # отфильтровать

Схема удаления и его защиты:

main: A ←──── B ←──── C
feature: D ←──── E ← невлитые коммиты
git branch -d feature
# error: The branch 'feature' is not fully merged.
# (в D и E нет пути parent-ами к main — данные будут недостижимы)
решения:
1) git merge feature ← влить сначала (правильный путь)
2) git branch -D feature ← «да, я знаю, что теряю D и E»
(объекты ещё живут в reflog ~90 дней, но без имени)

Переименование — тоже просто файл: git branch -m фактически делает mv .git/refs/heads/old .git/refs/heads/new. Если ветка была текущей, Git дополнительно правит .git/HEAD (ref: refs/heads/oldref: refs/heads/new).

Именование веток: конвенции, которые окупаются

Заголовок раздела «Именование веток: конвенции, которые окупаются»

Ветке — имя сразу, а не «test2-final». Имя — первое, что видят в git log, code review и списках PR; оно должно отвечать на вопрос «что и зачем» без открытия диффа. Сложившиеся префиксы:

feature/login-oauth ← новая функциональность
fix/session-race ← исправление бага (часто с номером: fix/PROJ-1234)
hotfix/payment-timeout ← срочный фикс в релиз (из Git Flow)
refactor/api-typing ← поведение то же, код другой
chore/update-deps ← рутина: зависимости, конфиги
release/2.4.0 ← ветка подготовки релиза

Три правила: строчные буквы и дефисы вместо пробелов (пробелы в именах веток — боль при скриптах и tab-completion), префикс-тип от conventional commits (история веток в remote читается как список задач), ссылка на тикет, если трекер есть (feature/PROJ-145-oauth). Команда git branch --list 'fix/*' тогда превращается в фильтр багфиксов, а не в лотерею.

git log (см. git-log(1)) показывает историю, достижимую из HEAD. Ключевые режимы:

Окно терминала
git log --oneline # один коммит = одна строка
git log --oneline --graph # ASCII-граф ветвлений
git log --oneline --graph --all # + ВСЕ refs (все ветки, теги)
git log --oneline --graph --decorate # + имена веток/тегов на коммитах
git log -p # + полный diff каждого коммита
git log --stat # + статистика (файлы, +/- строк)
git log --author="Иван" --since="2 weeks ago"
git log --grep="feat(auth)" # поиск по сообщениям

Настрой alias, это самая частая команда в жизни разработчика:

Окно терминала
git config --global alias.lg "log --oneline --graph --decorate --all"
git lg

Как читать ASCII-граф (потренироваться на интерактивных схемах — в тренажёре Learn Git Branching). Пример вывода git lg после типичной feature-работы:

* 4e8f1c2 (HEAD -> main) Merge branch 'feature/login'
|\
| * 71bf2c1 (feature/login) feat(auth): экран входа
| * 9c2d5a8 feat(auth): форма и валидация
|/
* 3b18e51 fix(ui): отступы в шапке
* ce01362 init

Разбор: коммиты идут сверху вниз от новых к старым; | — продолжение линии, \ и / — точки расхождения и слияния; (HEAD -> main) — где мы сейчас; (feature/login) — какие ветки на каких коммитах висят. Merge commit 4e8f1c2 имеет двух родителей — поэтому |\ слева: одна линия продолжилась вниз (первая родительская линия, от main), вторая свернулась из feature (/).

Симметричная картина для rebase (подробности — в следующей главе) будет выглядеть так — обрати внимание, как линия feature «переписывается» поверх main:

# после rebase feature на main:
* 8a9d0e2 (feature/login) feat(auth): экран входа
* 2f4b6c9 feat(auth): форма и валидация
* 3b18e51 (main) fix(ui): отступы в шапке
* ce01362 init

Одинаковые сообщения, но другие хэши — rebase создал копии коммитов. Умение видеть это в графе — половина понимания rebase.

Для истории одного файла есть два полезных флага:

Окно терминала
git log --follow --oneline -- src/app.ts # следить за переименованиями
git blame src/app.ts # кто написал каждую строку
  • Internals — ветки и HEAD здесь — это те самые файлы в .git/refs/ и .git/HEAD; commit — те самые неизменяемые объекты.
  • Staging/commitgit switch требует чистого статуса или аккуратного переноса правок (uncommitted changes следуют за тобой в другую ветку, если нет конфликтов) — фундамент для понимания stash.
  • Merge/rebase — вся механика слияний и перебазирования — это операции над указателями веток и цепочками родителей; теперь ты готов к ним по-настоящему.
  1. Коммиты «в никуда» в detached HEAD. Симптом: после git switch main правки пропали. Лечение: git reflog + git switch -c имя <хэш>. Профилактика: смотри первую строку git status перед каждым коммитом.
  2. git checkout origin/feature вместо git switch feature. Ты попадёшь в detached HEAD на remote-ref, а не на локальную ветку. Правильно: git switch feature (Git сам создаст локальную ветку, отслеживающую remote).
  3. git branch -d ругается, а нужно удалить срочно. -d — это фича, а не баг: он защищает от потери невлитых коммитов. Сначала подумай, куда делись D и E; если точно мусор — -D.
  4. Путать git restore файл и git restore --staged файл. Первый затирает правки в рабочей директории (нельзя отменить!), второй просто убирает из index. Это самая частая потеря данных новичка.
  5. Читать git log без --all и не видеть соседние ветки. История «исчезает» после переключения — нет, она просто не достижима из HEAD. Привыкай всегда к --graph --all (alias lg).
  6. Создавать ветки «от головы», когда HEAD в detached. Проверяй git status перед git switch -c: если ты в detached, новая ветка укажет на «чужой» коммит, а не на конец main.
  1. Что такое ветка в Git с точки зрения внутреннего устройства? Текстовый файл в .git/refs/heads/ со строкой хэша коммита (41 байт). Создание ветки — запись файла; коммит в ветку — перезапись этого файла на хэш нового коммита.
  2. Что такое HEAD и чем attached HEAD отличается от detached? Файл .git/HEAD. Attached: ref: refs/heads/<ветка> — HEAD указывает на ветку, коммит двигает ветку. Detached: прямой хэш коммита — коммиты никуда не привязываются и теряются при переключении (спасёт reflog).
  3. Чем git switch отличается от git checkout? switch — только переключение веток (безопаснее); restore — восстановление файлов. checkout — историческая «комбайн», делающая оба действия, из-за чего легко потерять правки.
  4. Почему ветки в Git дёшевы? Ветка — 41 байт на диск; объекты коммитов общие. Нет копирования файлов или истории — только новый указатель.
  5. git branch -d отказала удалять ветку. Почему и что делать? В ветке есть коммиты, недостижимые из текущей (невлитые). Влить (git merge), или осознанно удалить принудительно (-D), или сначала git cherry-pick/перенести нужное.
  6. Как прочитать ASCII-граф git log --graph? Новые коммиты сверху; | — линия ветки; \// — расхождение/слияние; метки (HEAD -> main), (feature) — какие refs на каких коммитах; merge-коммит — точка с двумя входами.
  7. git checkout <тег> — куда попадёшь? В detached HEAD: тег — это ref на коммит, а не ветка. Для работы нужна git switch -c имя <тег>.
  1. В песочном репозитории создай три ветки руками: через git branch и через создание файлов в .git/refs/heads/ (предварительно узнай хэши через git rev-parse). Критерий: git for-each-ref и ls .git/refs/heads/ показывают одинаковые ветки.
  2. Переключись на конкретный коммит по хэшу, сделай коммит, убедись через git status и cat .git/HEAD, что ты в detached HEAD. Затем «потеряй» коммит (переключись на main) и спаси его через reflog. Критерий: коммит снова адресуется именованной веткой.
  3. Настрой alias lg и разыграй сценарий: две ветки, три коммита в каждой, merge одной в другую. Критерий: по git lg ты можешь объяснить, какой коммит merge, кто его родители и почему граф выглядит именно так.
  4. Создай ветку, сделай в ней коммит, вернись на main. Попробуй git branch -d — получи ошибку. Влей ветку и удали. Критерий: после merge удаление проходит; объясни, что изменилось в графе достижимости.
  5. Переименуй текущую ветку и найди, что изменилось в .git/HEAD и в .git/refs/heads/. Критерий: git status показывает новое имя, старый файл refs исчез.
  6. Отрепетируй «спасение из detached HEAD» три раза подряд: commit → switch away → reflog → switch -c. Критерий: время спасения меньше двух минут, без паники и без пересоздания репозитория.