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

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

Одна из ключевых возможностей Git'а — переписывание истории, то есть изменение существующих коммитов. Я использую кавычки, потому что — несмотря на видимость — история 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 log скрывает все коммиты, недоступные по какому-либо указателю, например 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. Поэтому более подходящим является термин альтернативная история. Это также означает, что при необходимости можно отменить разрушительную команду commit --amend:

git reset --hard 5c7a782

AuthorDate vs. CommitDate

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

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

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

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

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

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

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  # безопасная версия

Теперь всё выглядит хорошо:

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

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

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

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

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

git pull --rebase

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

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

git reset --hard origin/main

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

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

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

Заключение

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

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

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

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

CSS с селекторами атрибутов

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

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