Pull to refresh

Тонкости благополучного git-merge

Reading time 8 min
Views 354K

Вступительное слово


Считается, что «киллер фичей» СКВ Git является легковесное ветвление. Я ощутил это преимущество в полной мере, ведь я перешел на Git с SVN, где ветвление было достаточно дорогим процессом: для создания ветки нужно было скопировать весь рабочий каталог. В Git все проще: создание ветки подразумевает лишь создание нового указателя на определенный коммит в папке .git/refs/heads, который является файлом с 40 байтами текста, хешем коммита.

Основными командами пользовательского уровня для ветвления в Git являются git-branch, git-checkout, git-rebase, git-log и, конечно же, git-merge. Для себя я считаю git-merge зоной наибольшей ответственности, точкой огромной магической энергии и больших возможностей. Но это достаточно сложная команда, и даже достаточно длительный опыт работы с Git порой бывает недостаточным для освоение всех ее тонкостей и умения применить ее наиболее эффективно в какой-либо нестандартной ситуации.

Попробуем же разобраться в тонкостях git-merge и приручить эту великую магию.

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

Анатомия команды


Если верить мануалу, команда имеет следующий синтаксис:

git merge [-n] [--stat] [--no-commit] [--squash] [--[no-]edit]
[-s <strategy>] [-X <strategy-option>]
[--[no-]rerere-autoupdate] [-m <msg>] [<commit>...]
git merge <msg> HEAD <commit>...
git merge --abort


По большому счету, в Git есть два вида слияния: перемотка (fast-forward merge) и «истинное» слияние (true merge). Рассмотрим несколько примеров обоих случаев.

«Истинное» слияние (true merge)


Мы отклоняемся от ветки master, чтобы внести несколько багов улучшений. История коммитов у нас получилась следующая:

master: A - B - C - D
                 \
feature:          X - Y


Выполним на ветке master git merge feature:

master: A - B - C - D - (M)
                 \      /
feature:          X - Y


Это наиболее частый паттерн слияния. В данном случае в ветке master создается новый коммит (M), который будет ссылаться на двух родителей: коммит D и коммит Y; а указатель master установится на коммит (M). Таким образом Git будет понимать, какие изменения соответствуют коммиту (M) и какой коммит последний в ветке master. Обычно коммит слияния делается с сообщением вроде «Merge branch 'feature'», но можно определить и свое сообщение коммита с помощью ключа -m.

Посмотрим историю коммитов в тестовом репозитории, который я создал специально для этого случая:

$ git log --oneline
92384bd (M)
bceb5a4 D
5dce5b1 Y
76f13e7 X
d1920dc C
3a5c217 B
844af94 A


А теперь посмотрим информацию о коммите (M):

$ git cat-file -p 92384bd
tree 2b5c78f9086384bd86a2ab9d00c7e41a56f01d04
parent bceb5a4ad88e80467404473b94c3e0758dd8e0be
parent 5dce5b1edef64bd0d4e1039061a77be4d7182678
author Andre <andrey.prokopyuk@gmail.com> 1380475972 +0400
committer Andre <andrey.prokopyuk@gmail.com> 1380475972 +0400

(M)


Мы видим двух родителей, объект-дерево, соответствующее данному состоянию файлов репозитория, а так же информацию о том, кто виновен в коммите.

Посмотрим, куда ссылается указатель master:

$ cat .git/refs/heads/master
92384bd77304c09b81dcc4485da165923b96ed5f


Действительно, он теперь передвинут на коммит (M).

Squash и no-commit


Но что делать, если за содержимое ветки feature вас могут побить? К примеру, улучшение было небольшим, и вполне могло уместиться в один логичный коммит, но так вышло, что посреди работы вам было нужно убегать на электричку, а продолжать уже дома? В таком случае есть два выхода: экспорт репозитория с последующим импортом на другой машине, либо (особенно когда до электрички 10 минут, а до вокзала около километра) — сделать push origin feature.

Заливать незаконченные коммиты в основную ветку плохо, и с этим нужно что-то делать. Одним из способов, и, пожалуй самым простым, является опция --squash.

git merge feature --squash объединит изменения всех коммитов ветки feature, перенесет их в ветку master и добавит в индекс. При этом коммит слияния не будет создан, вам нужно будет сделать его вручную.

Такого же поведения без параметра squash можно добиться, передав при слиянии параметр --no-commit.

В случае применения такого слияния коммиты ветки feature не будут включены в нашу историю, но коммит Sq будет содержать все их изменения:

master: A - B - C - D - Sq
                 \ 
feature:          X - Y



Позже, в случае выполнения «классического» git merge feature можно исправить это. Тогда история примет следующий вид:

master: A - B - C - D - Sq - (M)
                 \           /
feature:          X    -    Y



В случае, если вы выполнили слияние без коммита, а потом поняли, что совершили фатальную ошибку, все можно отменить простой командой: git merge --abort. Эта же команда может быть применена, если во время слияния произошли конфликты, а разрешать их в данный момент не хочется.

Перемотка (fast-forward merge)


Рассмотрим другой случай истории коммитов:

master: A - B - C
                 \
feature:          X - Y


Все как и в прошлый раз, но теперь в ветке master нет коммитов после ответвления. В этом случае происходит слияние fast-forward (перемотка). В этом случае отсутствует коммит слияния, указатель (ветка) master просто устанавливается на коммит Y, туда же указывает и ветка feature:

master, feature: A - B - C - X - Y


Чтобы предотвратить перемотку, можно использовать параметр --no-ff.
В случае, если мы выполним git merge feature --no-ff -m '(M)', мы получим уже такую картину:

master: A - B - C   -  (M)
                 \     /
feature:          X - Y


Если же для нас единственным приемлемым поведением является fast-forward, мы можем указать опцию --ff-only. В этом случае, если к слиянию не применима перемотка, будет выведено сообщение о невозможности совершить слияние. Именно так было бы, если бы мы добавили опцию --ff-only в самом первом примере, где после ответвления feature в ветке master был сделано коммит C.

Можно добавить, что при выполнении git pull origin branch_name применяется как раз что-то вроде --ff-only. То есть, в случае, если при слиянии с веткой origin/branch_name не приемлема перемотка, операция отменяется и выводится сообщении о невозможности выполнения.

Стратегии слияния


У команды git-merge есть интересный параметр, --strategy, стратегия. Git поддерживает следующие стратегии слияния:
  • resolve
  • recursive
  • ours
  • octopus
  • subtree


Стратегия resolve

Стратегия resolve — классическое трехсторонее слияние (three-way merge). Стандартный алгоритм трехстороннего слияния применяется для двух файлов с общим предком. Условно этот алгоритм можно представить в виде следующих шагов:
  1. поиск общего предка,
  2. поиск блоков, изменившихся в обеих версиях относительно общего предка,
  3. записываются блоки, оставшиеся без изменения,
  4. блоки, изменившиеся только в одном из потомков, записываются как измененные,
  5. блоки, изменившиеся в обеих версиях, записываются только если изменения идентичны, в ином случае объявляется конфликт, разрешение которого предоставляется пользователю.

Эта стратегия имеет один недостаток: в качестве общего предка двух веток всегда выбирается наиболее ранний общий коммит. Для случая из нашего первого примера это не страшно, можно смело применять git merge feature -s resolve, и результат будет ожидаемым:

master: A - B - C - D - (M)
                 \      /
feature:          X - Y


Здесь C — общий коммит двух веток, дерево файлов, соответствующее этому коммиту, принимается за общего предка. Анализируются изменения, произведенные в ветках master и feature со времен этого коммита, после чего для коммита (M) создается новая версия дерева файлов в соответствии с пунктами 4 и 5 нашего условного алгоритма.

В каком же случае проявляется недостаток стратегии resolve? Он проявляется в том случае, если для коммита (M) нам пришлось разрешить конфликты, после чего мы продолжили разработку и еще раз хотим выполнить git merge feature -s resolve. В этом случае в качестве общего предка снова будет использован коммит C, и конфликты произойдут снова и будут нуждаться в нашем вмешательстве.

Стратегия recursive

Данная стратегия решает проблемы стратегии resolve. Она так же реализует трехстороннее слияние, но в качестве предка используется не реальный, а «виртуальный» предок, который конструируется по следующему условному алгоритму:
  1. проводится поиск всех кандидатов на общего предка,
  2. по цепочке проводится слияние кандидатов, в результате чего появляется новый «виртуальный» предок, причем более свежие коммиты имеют более высокий приоритет, что позволяет избежать повторного проявления конфликтов.

Результат этого действия принимается за общего предка и проводится трехсторонее слияние.

Для иллюстрации этой стратегии позаимствуем пример из статьи Merge recursive strategy из блога «The plasticscm blog»:

Merge recursive

Итак, у нас есть две ветки: main и task001. И так вышло, что наши разработчики знают толк в извращениях: они слили коммит 15 из ветки main с коммитом 12 из ветки task001, а так же коммит 16 с коммитом 11. Когда нам понадобилось слить ветки, оказалось, что поиск реального предка — дело неблагодарное, но стратегия recursive с ее конструированием «виртуального» предка нам поможет. В результате мы получим следующую картину:

Merge recursive

Стратегия recursive имеет множество опций, которые передаются команде git-merge с помощью ключа -X:
  • ours и theirs
    Используются для автоматического разрешения конфликтов. Ours — предпочитать «нашу» версию, версию «dst», theirs — предпочитать «их» версию.
  • renormalize (no-renormalize)
    Предотвращает ложные конфликты при слиянии вариантов с разными типами перевода строк.
  • diff-algorithm=[patience|minimal|histogram|myers], а так же опция patience
    Выбор алгоритма дифференциации файлов.
    Дополнительную информацию об этих опциях можно найти в документации по git-diff. Если кратко, свойства этих алгоритмов следующие:
    default, myers — стандартный, жадный алгоритм. Он используется по умолчанию.
    minimal — производится поиск минимальнейших изменений, что занимает дополнительное время.
    patience — использовать алгоритм «patience diff». О нем можно почитать у автора алгоритма, либо в сокращенном варианте на SO.
    histogram — расширяет алгоритм patience с целью, описанной как «support low-occurrence common elements». Сказать честно, я не смог найти достаточно ясного ответа на вопрос, какие конкретно случаи подразумеваются и буду очень рад, если кто-нибудь поможет найти этот ответ.
  • ignore-space-change, ignore-all-space, ignore-space-at-eol
    Корни этих опций лежат, опять же, в git-diff и относятся к дифференциации файлов при слиянии.
    ignore-space-change — игнорируются различия в количестве пробелов, идущих подряд, а так же пробелы в конце строки,
    ignore-all-space — пробелы абсолютно игнорируются при сравнении,
    ignore-space-at-eol — игнорируются различия в пробелах в конце строки.
  • rename-threshold=<n>
    Данная опция задает порог, по достижении которого файл может считаться не новым, а переименованным файлом, которого git-diff не досчитался. Например, -Xrename-threshold=90% подразумевает, что переименованным считается файл, который содержит от 90% контента некоторого удаленного файла.
  • subtree[=<path>]
    Выполнение рекурсивного слияния с этой опцией будет более продвинутым вариантом стратегии subtree, где алгоритм основывается на предположении, как деревья должны совместиться при слиянии. Вместо этого в этом случае указывается конкретный вариант.


Стратегия octopus

Эта стратегия используется для слияние более чем двух веток. Получившийся в итоге коммит будет иметь, соответственно, больше двух родителей.

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

Стратегия ours

Не следует путать стратегию ours и опцию ours стратегии recursive.

Выполняя git merge -s ours obsolete, вы как бы говорите: я хочу слить истории веток, но проигнорировать все изменения, которые произошли в ветке obsolete. Иногда рекомендуют вместо стратегии ours использовать следующий вариант:

$ git checkout obsolete
$ git merge -s recursive -Xtheirs master


Стратегия ours — более радикальное средство.

Стратегия subtree

Для иллюстрации данной стратегии возьмем пример из главы Слияние поддеревьев книги «Pro Git».

Добавим в наш проект новые удаленный репозиторий, rack:

$ git remote add rack_remote git@github.com:schacon/rack.git
$ git fetch rack_remote
warning: no common commits
remote: Counting objects: 3184, done.
remote: Compressing objects: 100% (1465/1465), done.
remote: Total 3184 (delta 1952), reused 2770 (delta 1675)
Receiving objects: 100% (3184/3184), 677.42 KiB | 4 KiB/s, done.
Resolving deltas: 100% (1952/1952), done.
From git@github.com:schacon/rack
 * [new branch]      build      -> rack_remote/build
 * [new branch]      master     -> rack_remote/master
 * [new branch]      rack-0.4   -> rack_remote/rack-0.4
 * [new branch]      rack-0.9   -> rack_remote/rack-0.9
$ git checkout -b rack_branch rack_remote/master
Branch rack_branch set up to track remote branch refs/remotes/rack_remote/master.
Switched to a new branch "rack_branch"



Ясно, что ветки master и rack_branch имеют абсолютно разные рабочие каталоги. Добавим файлы из rack_branch в master с использованием squash, чтобы избежать засорения истории ненужными нам фактами:

$ git checkout master
$ git merge --squash -s subtree --no-commit rack_branch
Squash commit -- not updating HEAD
Automatic merge went well; stopped before committing as requested


Теперь файлы проекта rack у нас в рабочем каталоге.

Заключительное слово


Итак, я собрал вместе все знания, которые я получил за время работы с Git относительно благополучного git-merge. Я буду рад, если кому-то это поможет, но так же я буду рад, если кто-то поможет мне дополнить материал или исправить неточности и ошибки, если вдруг я допустил такие.
Tags:
Hubs:
+74
Comments 47
Comments Comments 47

Articles