Объяснение Git: Переписывание истории

Источник: «Git explained: Rewriting history»
Одна из основных функций Git — "переписывание истории", то есть "изменять" существующие коммиты. Я использую кавычки, потом что, несмотря на это история Git неизменна. Преднамеренно невозможно изменить существующий коммит с помощью обычных команд Git.

И всё же среди многих пользователей Git есть страх потерять изменения или просто создать беспорядок. На самом деле, любое зафиксированное изменение можно считать безопасным, т.е. его можно восстановить даже после «изменения» истории.

Это первая часть из серии Объяснение Git:

Что означает переписывание?

Зачем вообще нужно изменять историю Git? Представим, что вы допустили опечатку в самом последнем коммите:

* 5c7a782 - (HEAD -> main) Fix deefct 47
* fb2546f - Add checkout page

Обычно мы используем такие команды, как reset или rebase (-i), что бы "переписать" историю Git. Однако исправление последнего коммита довольно распространено, поэтому есть более простая альтернатива:

git commit --amend -m "Fix defect 47"

Теперь история Git показывает правильное сообщение:

* 2b049ea - (HEAD -> main) Fix defect 47
* fb2546f - Add checkout page

Похоже, мы только что изменили последний коммит. Однако хэш изменился с 5c7a782 на 2b049ea, что означает, что мы создали новый коммит. Причина в этом:

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

Поскольку мы, только что изменили сообщение коммита, Git создал новый коммит с другим хэшем. То же самое происходит при "перемещении" (например, перебазировании), потому что родительский коммит изменяется.

Но куда делся другой коммит? По умолчанию git log скрывает каждый коммит, недоступный из HEAD или любой именованной ветки. Эти недоступные коммиты можно отобразить с помощью флага --reflog:

git log --graph --oneline --reflog
* 2b049ea - (HEAD -> main) Fix defect 47
| * 5c7a782 - Fix deefct 47
|/
* fb2546f - Add checkout page

Как мы видим, "изменение" истории — это не что иное, как создание новых коммитов и перемещение HEAD и main указателей. Следовательно, в данной ситуации, более подходит термин «альтернативная история». Это, так же означает, что при необходимости мы можем отменить нашу "деструктивную" команду commit --amend.

Сборка мусора

Ранее, я утверждал, что все коммиты можно считать безопасными. Однако есть ограничение:

Сборщик мусора Git автоматически удалит все недостижимые коммиты через определённое время (по умолчанию, 30 дней).

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

git branch my-branch 5c7a782

Изменение публичной истории

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

Общепринятая лучшая практика Git гласит:

Не изменяйте публичную историю

Я думаю этот совет для новичков в Git, но он может быть ограничивающим, если вы или ваш коллега знаете, что делаете.

Для начала, давайте рассмотрим последствия перезаписи публичной истории Git. Предположим, ошибочный коммит был отправлен на origin remote. После запуска git commit --amend журнал истории будет выглядеть так:

* 2b049ea - (HEAD -> main) Fix defect 47
| * 5c7a782 - (origin/main) Fix deefct 47
|/
* fb2546f - Add checkout page

Если мы попытаемся отправить исправленный коммит в origin, мы получим ошибку:

$ git push
To ../origin/
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to '../origin/'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Это ожидаемое поведение, потому что по умолчанию git push позволяет добавлять новые коммиты только в последнюю "подсказку" удалённого (источника). Git предлагает простое решение: "интегрировать удалённые(remote) изменения". В нашем случае, git pull эквивалентен git merge origin/main, чего мы не хотим. Вместо этого, мы хотим заменить отправленный коммит. Мы можем сделать это с помощью принудительного пуша git push -f:

git push -f
git push --force-with-lease # safer version

Теперь всё выглядит нормально:

* 2b049ea - (HEAD -> main, origin/main) Fix defect 47
* fb2546f - Add checkout page

Но теперь все ваши коллеги столкнутся с той же проблемой, что и вы ранее. Вот почему в проектах часто запрещают принудительный пуш для основных ветвей (например, main и develop).

Как нам справится с этой ситуацией?

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

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

git pull --rebase

Эта команда получит удалённую(remote) ветку и переустановит все локальные коммиты поверх неё. Изменённые коммиты будут разрешены автоматически (поэтому коммит с опечаткой будет пропущен).

Другие варианты может быть труднее исправить, включая (интерактивное) перебазирование и выбор лучших вариантов. Всегда обдумывайте компромиссы, прежде чем выполнять принудительный пуш.

Что делать если вы работаете один с публичной веткой? Тогда вы, как правило, можете принудительно пушить столько — сколько захотите. Опять же, общение здесь является ключевым моментом.

Заключение

Контроль версий — это недооценённый навык. Большинство инженеров-программистов используют его ежедневно, тем не менее, многие не готовы вкладываться больше, чем необходимо в его изучение. Это нормально, но знание большего, чем commit/push/pull, по крайней мере, сделают вас более эффективным. Это так же поможет вам решить проблемы, с которыми вы и ваши коллеги можете столкнуться.

Я надеюсь, что эта статья объясняет стандартное поведение Git — "сеть безопасности" и мотивирует вас испробовать некоторые расширенные функции.

Дополнительные материалы

Предыдущая Статья

CSS: Введение в селекторы атрибутов

Следующая Статья

Объяснение Git: Диапазоны коммитов