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).

Файл

Ветка develop (John)

Ветка feature (Alex)

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 помогает определить роль файла в конфликте и механическое действие

Флаг в конфликте

Действие

Их изменения

Наши изменения

DU Dennis.txt

git add

Файл удален

Файл изменен

DD Ken.txt

git rm

Файл удален

Файл удален

AU engineers/Ken.txt

git rm

Новое имя или локация

Файл отсутствует

UA scientists/Ken.txt

git add

Файл отсутствует

Новое имя или локация

AA Margaret.txt

git add

Файл добавлен

Файл добавлен

UD File.txt

git rm

Файл изменен

Файл удален

UU File.txt

git add

Файл изменен

Файл изменен

Извлекаем состояние проекта из коммитов. Про это есть отдельная статья на Хабре.

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. Если есть фидбек или вопрос, я отвечу в комментариях.

Ссылка на гитхаб

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Так «merge» или «rebase»?
57.14%Делаю rebase, и мне это нравится больше16
7.14%Делаю rebase, но потому что так принято в команде2
25%Делаю merge, и мне это нравится больше7
3.57%Делаю merge, но потому что так принято в команде1
7.14%Мне без разницы2
Проголосовали 28 пользователей. Воздержались 3 пользователя.