git rebase это отличный способ сделать историю линейной и визуально красивой. Но для каждого коммита, у которого возникает конфликт, приходится его исправлять и делать git rebase --continue. В случае длинных веток таких остановок для исправления конфликтов может быть довольно много. В этой статье я расскажу про нестандартный метод, как можно исправить все эти конфликты разом, что позволяет сделать git rebase быстро и чисто механически.


Метод "rebase via merge"
Готовый скрипт лежит на github, а также приведен ниже. Этот метод использует низкоуровневые команды git, но погружаться в них совершенно не обязательно, так как скрипт полностью интерактивный. Прежде чем перейти к деталям, сравним этот метод с обычными подходами по стилю исправления конфликтов.
Стандартный "merge"
Плюсы
Сохраняет оригинальную историю, подходит для совместной работы над веткой.
Все конфликты видны сразу и в минимальном объеме.
Конфликты нужно исправить только один раз.
Минусы
Каждый раз добавляется merge-коммит.
История изменений становится нелинейной.
Репозиторий становится более сложным для восприятия и работы.
Стандартный "rebase"
Плюсы
Простая, линейная история.
Легко отслеживать изменения визуально.
Минусы
Конфликты исправляются на уровне отдельных коммитов, с остановкой процесса.
В некоторых случаях это даже увеличивает суммарный объем конфликтов.
Переписывает историю, поэтому не подходит для совместной работы над веткой.
Метод "rebase via merge"
Плюсы
Сразу показывает все конфликты.
Объем конфликтов и работа по исправлению минимальны, как в случае "merge".
Процесс "rebase" полностью автоматический, конфликты исправляются механически.
История линейная как и у "rebase".
Минусы
Промежуточные коммиты могут не компилироваться, над ними нет ручного контроля.
Иногда автоматически создается дополнительный коммит, в случае если механическое исправление конфликтов не совпало с ручным. Опционально, можно выключить.
Переписывает историю (аналогично "rebase").
Идея метода и алгоритм
Коммит в git-е описывает не изменения, а состояние проекта целиком, то есть, зная хэш коммита, можно в точности восстановить проект. И если форсировать merge, можно сразу увидеть все конфликты, а исправив их, получить хэш нужного состояния проекта, с учетом всех изменений в обоих ветках. Далее, если запустить rebase c флагом механического разрешения конфликтов в "нашу" пользу, то он гарантировано завершится очень быстро. И останется только сравнить состояние проекта, чтобы оно было правильным.
Скрипт реализует следующий алгоритм:
Показывает название веток и последний коммит на обоих. Проверяет, что rebase возможен. Ждет подтверждения от пользователя прежде чем начать действовать.
Переключает с текущей ветки на сам коммит (режим detached head).
Запускает скрытый merge из базовой ветки относительно текущего коммита.
Если есть конфликты, то они вручную исправляются на этом шаге.
Скрипт запоминает результат merge-а.
Скрипт возвращается на ветку и запускает для нее полностью автоматический rebase.
Форсируется выбор "наших" изменений вместо "их" (что не всегда логически корректно).
Сравнивает результат rebase-a с тем, как конфликты были исправлены вручную.
Если есть разница, то добавляет один коммит с корректным состоянием проекта.
Стоит отметить:
Если нет конфликтов, то не будет и никакого ручного исправления.
Конечный результат гарантировано повторяет результат ручного исправления конфликтов.
Все "наши" уникальные изменения остаются в истории ветки.
В целом, цель метода это минимизация усилий по исправлению конфликтов и корректность конечного результата. Но на уровне отдельных коммитов, исправление конфликта может быть некорректным.
Как скачать и запустить
Скачиваем скрипт и делаем его исполняемым. Эти команды работают универсально для macOS / Linux / Windows (git-bash).
curl https://raw.githubusercontent.com/capslocky/git-rebase-via-merge/master/git-rebase-via-merge.sh -o ~/git-rebase-via-merge.sh chmod +x ~/git-rebase-via-merge.sh
И используем этот скрипт:
~/git-rebase-via-merge.sh
Вместо обычного rebase:
git rebase origin/develop
Вообще, более удобно, если создать алиас:
git config --global alias.rvm '!bash ~/git-rebase-via-merge.sh'
Тогда достаточно ввести:
git rvm
По умолчанию базовая ветка origin/develop, но это можно исправить в скрипте или указать динамически:
git rvm origin/main
Демо репозиторий
Проблема будет хорошо видна на примере следующего небольшого репозитория на github. Две ветки develop и feature вносят разные изменения в одни и те же файлы. Причем тут два типа конфликтов: только содержимое (Linus.txt, Margaret.txt) и на уровне файловой структуры (Ken.txt, Dennis.txt).
Файл | Ветка | Ветка |
Linus.txt | изменён | изменён |
Margaret.txt | добавлен | добавлен |
Ken.txt | перемещен в папку "engineers" | перемещен в папку "scientists" |
Dennis.txt | удален | изменён |

Если делать обычный rebase, то он остановится 5 раз и каждый раз нужно будет исправлять конфликт и идти дальше с помощью git rebase --continue.
git checkout feature git rebase develop
Тогда как, используя данный метод, будет так:
Все конфликты решаются за один раз, в самом начале.
rebase запускается и завершается автоматически.
Результат это обычная прямолинейная топология.
Конечный код полностью отражает то, как были исправлены конфликты.
git checkout feature ~/git-rebase-via-merge.sh
В начале скрипт покажет сами ветки и запрос на продолжение:
This script will perform a rebase via merge. Current branch: feature (ce0ef5b) Alex | 2 weeks ago | Moved Ken to scientists Base branch: origin/develop (04a5062) John | 2 weeks ago | Removed Dennis Continue (c) / Abort (a)
Выбираем c чтобы продолжить, и сразу увидем все конфликты. Необходимо исправить их вручную, сделать stage всех изменений, не коммитить, и продолжить.
CONFLICT (modify/delete): Dennis.txt deleted in origin/develop and modified in HEAD. Version HEAD of Dennis.txt left in tree. CONFLICT (rename/rename): Ken.txt renamed to scientists/Ken.txt in HEAD and to engineers/Ken.txt in origin/develop. Auto-merging Linus.txt CONFLICT (content): Merge conflict in Linus.txt Auto-merging Margaret.txt CONFLICT (add/add): Merge conflict in Margaret.txt Automatic merge failed; fix conflicts and then commit the result. Fix all conflicts in the following files, stage all changes, do not commit, and type 'c': Dennis.txt Ken.txt Linus.txt Margaret.txt engineers/Ken.txt scientists/Ken.txt Continue merge (c) / Abort merge (a)
Далее скрипт сам завершает merge и делает форсированный rebase.
[detached HEAD 83b1545] Hidden orphaned commit with merge result. Merge succeeded. The target state is: 83b1545 Starting rebase. Any conflicts will be resolved automatically. CONFLICT (modify/delete): Dennis.txt deleted in HEAD and modified in 8d48e53 (Changed Dennis). Version 8d48e53 (Changed Dennis) of Dennis.txt left in tree. File-level conflict detected. Removing their file, keeping ours. DU Dennis.txt [detached HEAD ec7a42f] Changed Dennis Author: Alex <alex.t@mail.org> 1 file changed, 5 insertions(+) create mode 100644 Dennis.txt CONFLICT (rename/rename): Ken.txt renamed to engineers/Ken.txt in HEAD and to scientists/Ken.txt in ce0ef5b (Moved Ken to scientists). File-level conflict detected. Removing their file, keeping ours. DD Ken.txt AU engineers/Ken.txt UA scientists/Ken.txt rm 'Ken.txt' rm 'engineers/Ken.txt' [detached HEAD 3b0e034] Moved Ken to scientists Author: Alex <alex.t@mail.org> 1 file changed, 0 insertions(+), 0 deletions(-) rename {engineers => scientists}/Ken.txt (100%) Restoring the project state from the hidden result with one additional commit. Updating 3b0e034..109d7a3 Fast-forward Linus.txt | 1 + 1 file changed, 1 insertion(+) Done. Current branch: feature (109d7a3) Alex | 0 seconds ago | Rebase via merge. 'feature' rebased on 'origin/develop'.
Технические детали
Вот так запускается merge. Он проходит полностью в режиме detached head, то есть без активной ветки.
git checkout "$current_branch_hash" git merge "$base_branch" -m "Hidden orphaned commit with merge result."
Возврат на ветку и запуск rebase, с автоматическим выбором "наших" изменений при конфликтах. В отличие от merge, этот флаг у rebase действует наоборот: "theirs" это "наш" вариант, "ours" это "их" вариант.
git checkout "$current_branch" git rebase "$base_branch" -X theirs
В случае конфликта на уровне файловой системы, удаляем "их" файлы, добавляем все остальное и идем дальше.
git status --porcelain | grep -E "^(DD|AU|UD) " | cut -c4- | xargs -r git rm -- git add -A git rebase --continue
Команда git status --porcelain помогает определить роль файла в конфликте и механическое действие
Флаг в конфликте | Действие | Их изменения | Наши изменения |
|
| Файл удален | Файл изменен |
|
| Файл удален | Файл удален |
|
| Новое имя или локация | Файл отсутствует |
|
| Файл отсутствует | Новое имя или локация |
|
| Файл добавлен | Файл добавлен |
|
| Файл изменен | Файл удален |
|
| Файл изменен | Файл изменен |
Извлекаем состояние проекта из коммитов. Про это есть отдельная статья на Хабре.
current_tree=$(git cat-file -p HEAD | grep "^tree") result_tree=$(git cat-file -p "$hidden_result_hash" | grep "^tree")
И если они не совпадают, то создаем коммит с нужным состоянием проекта.
git commit-tree $hidden_result_hash^{tree} -p HEAD -m "$additional_commit_message" git merge --ff "$additional_commit_hash"
Критика
Почему не 'rerere'?
Функция rerere помогает автоматически исправить повторяющийся конфликт, когда он совпадает с тем, что когда-то раньше было исправлено вручную. Однако rerere всегда добавляет определенный риск, так как эта функция скрывает конфликты.
Коммиты будут показывать неправильное исправление конфликта.
Да, так может быть, потому что иногда логически корректно выбрать "их" вариант или объединить оба варианта. В таком случае дополнительный финальный коммит гарантировано вернет корректное исправление конфликта. Важно также то, что в истории останется изначальный "наш" вариант кода, и его даже можно cherry-pick-нуть.
Дополнительный коммит выглядит ужасно
Он не всегда нужен, но это цена автоматизации. На самом деле, он легко убирается, достаточно раскомментировать эти две строчки в скрипте, таким образом его изменения просто переносятся на предыдущий коммит.
git reset --soft HEAD~1 git commit --amend --no-edit
Полный текст скрипта
Файл git-rebase-via-merge.sh
Содержимое скрипта
#!/usr/bin/env bash # # https://github.com/capslocky/git-rebase-via-merge default_base_branch="origin/develop" base_branch=${1:-$default_base_branch} export GIT_ADVICE=0 set -e main() { echo "This script will perform a rebase via merge." echo init git checkout --quiet "$current_branch_hash" # checkout to detached head (no branch, only commit) git merge "$base_branch" -m "Hidden orphaned commit with merge result." || true echo if [ -n "$(get_unstaged_files)" ]; then prompt_user_to_fix_conflicts fi hidden_result_hash=$(get_hash HEAD) echo "Merge succeeded. The target state is: $hidden_result_hash" echo "Starting rebase. Any conflicts will be resolved automatically." echo git checkout --quiet "$current_branch" git rebase "$base_branch" -X theirs 2>/dev/null || true # here option 'theirs' means choosing our changes. while [ -n "$(get_unstaged_files)" ]; do echo "File-level conflict detected. Removing their file, keeping ours." # e.g. parallel file rename git status --porcelain git status --porcelain | grep -E "^(DD|AU|UD) " | cut -c4- | xargs -r git rm -- git add -A echo git -c core.editor=true rebase --continue 2>/dev/null || true # suppressing opening commit message editor done current_tree=$(git cat-file -p HEAD | grep "^tree") result_tree=$(git cat-file -p "$hidden_result_hash" | grep "^tree") if [ "$current_tree" != "$result_tree" ]; then echo "Restoring the project state from the hidden result with one additional commit." echo additional_commit_message="Rebase via merge. '$current_branch' rebased on '$base_branch'." additional_commit_hash=$(git commit-tree $hidden_result_hash^{tree} -p HEAD -m "$additional_commit_message") git merge --ff "$additional_commit_hash" # uncomment if you want to exclude additional commits completely: # git reset --soft HEAD~1 # git commit --amend --no-edit echo fi echo "Done. Current branch:" echo "$(git branch --show-current) ($(get_hash HEAD))" show_commit HEAD exit 0 } init() { if [ -d "$(git rev-parse --git-path rebase-merge)" ]; then echo "Can't rebase. Rebase in progress detected. Continue or abort existing rebase." exit 1 fi if [ -f "$(git rev-parse --git-path MERGE_HEAD)" ]; then echo "Can't rebase. Merge in progress detected. Continue or abort existing merge." exit 1 fi current_branch=$(git branch --show-current) current_branch_hash=$(get_hash "$current_branch") base_branch_hash=$(get_hash "$base_branch") if [ -z "$current_branch" ]; then echo "Can't rebase. There is no current branch: detached head." exit 1 fi if [ -z "$base_branch_hash" ]; then echo "Can't rebase. Base branch '$base_branch' not found." exit 1 fi echo "Current branch:" echo "$current_branch ($current_branch_hash)" show_commit "$current_branch_hash" echo echo "Base branch:" echo "$base_branch ($base_branch_hash)" show_commit "$base_branch_hash" echo if [ -n "$(get_any_changed_files)" ]; then echo "Can't rebase. You need to commit changes in the following files:" echo get_any_changed_files exit 1 fi if [ "$base_branch_hash" = "$current_branch_hash" ]; then echo "Can't rebase. Current branch is equal to the base branch." exit 1 fi if [ -z "$(git rev-list "$base_branch" ^"$current_branch")" ]; then echo "Can't rebase. Current branch is already rebased." exit 1 fi if [ -z "$(git rev-list ^"$base_branch" "$current_branch")" ]; then echo "Can't rebase. Current branch has no any unique commits. You can do fast-forward merge." exit 1 fi echo "Continue (c) / Abort (a)" read input if [ "$input" != "c" ]; then echo "Aborted." exit 1 fi } prompt_user_to_fix_conflicts() { echo "Fix all conflicts in the following files, stage all changes, do not commit, and type 'c':" get_unstaged_files echo while true; do echo "Continue merge (c) / Abort merge (a)" read input echo if [ "$input" = "c" ]; then if [ -n "$(get_unstaged_files)" ]; then echo "There are still unstaged files:" get_unstaged_files echo else git -c core.editor=true merge --continue # suppressing opening commit message editor break fi elif [ "$input" = "a" ]; then echo "Aborting merge." git merge --abort git checkout "$current_branch" exit 2 else echo "Invalid option: $input" fi done } get_any_changed_files() { git status --porcelain --ignore-submodules=dirty | cut -c4- } get_unstaged_files() { git status --porcelain --ignore-submodules=dirty | grep -v "^. " | cut -c4- } get_hash() { git rev-parse --short "$1" 2>/dev/null || true } show_commit() { git log -n 1 --pretty=format:"%<(20)%an | %<(14)%ar | %s" "$1" } main
Кому этот метод не подходит
В следующих случаях лучше его не применять:
Нужно билдить каждый промежуточный коммит, или чтобы он всегда показывал правильное ручное исправление конфликта.
Дополнительный коммит не допустим (пример на картинке ниже). Но его можно полностью исключить.

Итог
Я опубликовал этот скрипт несколько лет назад, но только недавно добавил автоматическое исправление конфликтов файловой системы, а также обновил README. Если есть фидбек или вопрос, я отвечу в комментариях.
Ссылка на гитхаб
