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

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

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

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

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

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

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

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

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

Повторный просмотр истории Git показывает правильное сообщение:

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

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

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

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

Недоступные коммиты

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

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

В качестве альтернативы можно использовать git reflog для поиска недоступных коммитов:

git reflog
2b049ea (HEAD -> master) HEAD@{0}: commit (amend): Fix defect 47
5c7a782 HEAD@{1}: commit: Fix deefct 47
fb2546f HEAD@{2}: commit: Add checkout page

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

git reset --hard 5c7a782

Дата автора и дата коммита

Git хранит две временные метки для каждого коммита:

При создании нового коммита обе временные метки будут одинаковыми. Однако при изменении существующего коммита они будут отличаться. При использовании таких команд, как show или log, Git по умолчанию отображает дату коммита. Чтобы увидеть обе временные метки, используйте более полный формат:

$ git show --format=fuller 2b049ea
commit 2b049eadac74e183e48b918e377e41765fca2a99
Author: Darek Kay
AuthorDate: Thu Mar 31 19:18:02 2022
Commit: Darek Kay
CommitDate: Fri May 6 18:26:49 2022

Если при изменении истории Git требуется синхронизировать дату коммита с датой оригинала (автора), используйте флаг --committer-date-is-author-date:

git rebase -i --committer-date-is-author-date

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

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

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

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

git branch my-branch 5c7a782

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

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

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

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

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

Сначала посмотрим, к каким последствиям приведёт переписывание публичной истории Git. Предположим, что исправленный коммит из предыдущего раздела уже был выгружен на удалённый origin. После выполнения команды 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 разрешено добавлять новые коммиты только на последнюю вершину удалённого (origin). Git предлагает простое решение: интегрировать удалённые изменения. В нашем случае git pull эквивалентен git merge origin/main, а это не то, что нам нужно. Вместо этого мы хотим заменить удалённый коммит. Этого можно добиться с помощью force push:

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

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

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

Но теперь и Джанет, и Стив, и все остальные ваши коллеги, работающие в ветке main, получат ту же проблему, что и вы раньше. Именно поэтому в проектах часто запрещён force push для общих веток (например, main, develop).

Как поступить в такой ситуации?

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

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

git pull --rebase

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

Другим решением может быть отбрасывание всех локальных изменений и сброс локальной main-ветви на origin/main:

git reset --hard origin/main

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

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

В целом, не следует изменять публичную историю на ветках, над которыми работают несколько человек.

Заключение

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

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

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

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

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

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

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