Вы собираетесь слить ветку, над которой долго работали, и хотите заранее понять, насколько всё будет болезненно. Не узнать, сколько проблем возникло после слияния, а предсказать, сколько их будет. Десять конфликтующих файлов или двести? Пять минут на исправления или целый день? Можно мерджить прямо сейчас или лучше отложить до обеда?

Git всё это время умел отвечать на такой вопрос. Просто запуск git merge с последующим разбором последствий — не лучший способ получить ответ.

Обычная проверка затрагивает рабочее дерево

Стандартный приём — запустить слияние без создания коммита и посмотреть, что получится:

$ git merge --no-commit --no-ff feature
Auto-merging shared.txt
CONFLICT (content): Merge conflict in shared.txt
Automatic merge failed; fix conflicts and then commit the result.

Ответ вы получили. Но ради него Git изменил ваше рабочее дерево:

$ git status --porcelain
UU shared.txt

Теперь репозиторий находится в состоянии незавершённого слияния с конфликтами, хотя вы этого вовсе не хотели. Приходится откатывать операцию:

$ git merge --abort

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

А если вы хотите выполнить слияние в ветку, на которую сейчас не переключены, такой способ вообще не подойдёт. Сначала придётся переключиться на нужную ветку.

Git может выполнить слияние прямо в памяти

Начиная с версии 2.38, команда git merge-tree умеет выполнять полноценное слияние, включая обнаружение переименованных файлов и все остальные механизмы. Результат записывается в базу объектов Git, а рабочее дерево, индекс Git и HEAD остаются нетронутыми:

$ git merge-tree --write-tree --name-only main feature
c9cc90770ebb9dee14e212f168dd40059cfb605a
shared.txt

Auto-merging shared.txt
CONFLICT (content): Merge conflict in shared.txt

Код завершения показывает результат:

Код завершения

Значение

0

Конфликтов нет. Слияние пройдёт успешно.

1

Возник конфликт или Git вообще отказался выполнять слияние.

Другой

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

Если Git смог выполнить слияние, в первой строке вывода будет OID результирующего дерева слияния. Если возникли конфликты, дальше перечисляются пути к конфликтующим файлам, затем идёт пустая строка и сообщения в понятном человеку виде. При слиянии без конфликтов выводится только первая строка.

В этом примере слияние выполняется с веткой sidebranch, которая ответвилась ещё до изменений в main и добавляет лишь один файл, которого в main никогда не было:

$ git merge-tree --write-tree --name-only main sidebranch
bab3bcfcd19b35a6ecb2881d1602cf080e1cb547
$ echo $?
0

Но с кодом завершения 1 есть нюанс. Сам по себе он ещё не означает, что возник конфликт. Попробуйте выполнить слияние с несуществующей веткой — тоже получите 1, только стандартный вывод (stdout) будет совершенно пустым, а сообщение об ошибке попадёт в стандартный поток ошибок (stderr). Поэтому проверять нужно не только код завершения, но и stdout. Если вывод пуст, значит, Git отказался выполнять слияние. Это не «конфликт в нуле файлов», а ситуация, когда результат неизвестен.

Код 0 тоже охватывает сразу несколько случаев, и это важно учитывать при разработке инструментов. Он возвращается и после полноценного трёхстороннего слияния, и при обычном слиянии перемоткой (fast‑forward), и когда ветка уже актуальна и мерджить нечего. merge-tree не различает эти ситуации. Если вашему интерфейсу важно показывать их по‑разному, понадобится дополнительная проверка через merge-base.

При этом рабочее дерево остаётся нетронутым:

$ git status --porcelain
$ git rev-parse --abbrev-ref HEAD
main

Команда настолько независима от текущего состояния репозитория, что прекрасно работает даже в голом репозитории (bare repository), где рабочего дерева вообще нет. Именно поэтому она так полезна при разработке инструментов: можно предварительно проверить слияние любой ветки с любой другой, не переключаясь ни на одну из них.

Результирующее дерево слияния — настоящий объект Git

Полученный OID — не просто идентификатор результата проверки. Он указывает на реальный объект дерева в базе объектов Git, с которым можно работать любыми командами, поддерживающими чтение дерева.

Хотите заранее посмотреть, как именно выглядит конфликт, прежде чем решать, что с ним делать? Прочитайте файл прямо из результата слияния, вместе со всеми маркерами конфликта:

$ TREE=$(git merge-tree --write-tree --name-only main feature | head -1)
$ git cat-file -p "$TREE:shared.txt"
line1
<<<<<<< main
MAIN
=======
FEATURE
>>>>>>> feature
line3

Хотите получить сводку изменений (diffstat), которые внесло бы слияние?

$ git diff --stat main "$TREE"
 shared.txt | 4 ++++
 1 file changed, 4 insertions(+)

Но к этой статистике стоит относиться внимательно. При конфликте содержимого, как в нашем примере, результирующее дерево слияния содержит файл с маркерами конфликта. Поэтому они тоже учитываются при подсчёте изменений. При стандартном формате представления конфликтов три из четырёх добавленных строк — это <<<<<<<, ======= и >>>>>>>, которые мы уже видели в выводе cat-file. Это не реальные изменения, с которыми придётся работать. Если установить для merge.conflictStyle значение diff3 или zdiff3, то для того же слияния статистика покажет уже шесть добавленных строк: в каждом конфликте появится дополнительный блок ||||||| с содержимым общей базовой версии.

Со структурными конфликтами, когда ветки расходятся в том, должен ли файл вообще существовать и где он должен находиться (подробнее об этом ниже), всё сложнее. Поэтому механически вычитать строки с маркерами нельзя. При конфликте modify/delete Git сохраняет целиком файл из одной ветки без каких‑либо маркеров, и статистика отражает только реальные изменения. А вот при rename/rename, когда обе ветки ещё и отредактировали файл, маркеры записываются по обоим путям, поэтому количество изменений снова оказывается завышенным. При конфликте file/directory Git вообще показывает переименование файла с изменённым именем, которое вы не узнаете. Так что воспринимайте эту статистику как приблизительную оценку, а не как точный подсчёт.

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

Ловушка: ‑X ours делает меньше, чем кажется

А теперь о нюансе, из‑за которого ваш инструмент может выдавать неверные результаты.

Если предварительный просмотр показывает конфликты, возникает вполне логичная мысль: хорошо, запустим слияние с параметром стратегии и позволим Git самому выбрать нужную сторону. Вы прикидываете, что -X ours со всем разберётся, и сообщаете пользователю: «3 конфликта, все автоматически разрешаемые».

Иногда это действительно так. -X ours вполне успешно разрешает конфликт содержимого из нашего примера:

$ git merge-tree --write-tree --name-only -X ours main feature
51c500ddf270e2f1f6bdf80e5af7fb598c95910b
$ echo $?
0

Всё чисто. Другое результирующее дерево слияния, никаких конфликтов, код завершения 0.

Теперь повторим эксперимент, но на этот раз в ветке feature файл был изменён, а в main удалён:

$ git merge-tree --write-tree --name-only main feature
61eb2660b922022a67457fa4d76a1687b447e0ee
doomed.txt

CONFLICT (modify/delete): doomed.txt deleted in main and modified in feature.

$ git merge-tree --write-tree --name-only -X ours main feature
61eb2660b922022a67457fa4d76a1687b447e0ee
doomed.txt

CONFLICT (modify/delete): doomed.txt deleted in main and modified in feature.

Посмотрите на OID результирующих деревьев. Они полностью совпадают. -X ours не разрешил конфликт, не повлиял на результат и не изменил в нём ни единого байта. С -X theirs получится точно такой же OID.

Причина в том, что -X ours и -X theirs работают только с конфликтами содержимого, когда для одного пути есть два варианта текста и Git нужно лишь указать, какой из них выбрать. Со структурными конфликтами эти параметры ничего сделать не могут, поскольку здесь разногласие касается самого существования файла или его расположения:

  • modify/delete — в одной ветке файл изменили, а в другой удалили;

  • rename/delete — в одной ветке файл переименовали или переместили, а в другой удалили;

  • rename/rename — один и тот же файл в двух ветках переименовали по‑разному;

  • file/directory — по одному и тому же пути в одной ветке находится файл, а в другой каталог.

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

Но ориентироваться только на то, выглядит ли конфликт структурным, тоже не стоит. Важно другое: есть ли два варианта содержимого, между которыми можно выбрать. Например, add/add выглядит как структурный конфликт, но на самом деле обе ветки создали файл по одному и тому же пути. Значит, есть два варианта текста, из которых можно выбрать, и -X ours успешно разрешает такой конфликт с кодом завершения 0. Если сомневаетесь, лучше проверить конкретный случай: название категории мало что говорит о том, удастся ли разрешить конфликт автоматически.

Поэтому, если вы хотите предсказать результат слияния с параметром стратегии, повторно запустите merge-tree с этим параметром и покажите пользователю фактический результат. Нельзя просто взять список конфликтов, полученный без параметров стратегии, и пометить их как «автоматически разрешаемые». Именно так можно пропустить конфликты, о которых пользователю особенно важно знать заранее.

Во что обходится такая проверка

Утверждение, что команда «ничего не затрагивает», не совсем точно. Причём сразу по двум причинам.

Во‑первых, она записывает объекты. Параметр --write-tree делает ровно то, что обещает: сохраняет результирующее дерево слияния и все созданные при слиянии blob‑объекты в базе объектов Git. Сколько именно объектов появится, зависит от конкретного слияния.

Вернёмся к нашему временному репозиторию с shared.txt. Сначала обнулим количество несжатых объектов (loose objects), чтобы можно было точно увидеть разницу. Делайте это только во временном репозитории: команда git gc --prune=now безвозвратно удаляет недостижимые объекты, в том числе те, из которых ещё можно было бы восстановить удалённые stash‑записи.

Теперь, когда исходное количество объектов равно нулю, конфликт содержимого добавит два новых объекта: дерево и blob‑объект с файлом, содержащим маркеры конфликта.

$ git gc --prune=now      # только для временных репозиториев — удаляет недостижимые объекты
$ git count-objects -v | grep '^count:'
count: 0
$ git merge-tree --write-tree --name-only main feature >/dev/null
$ git count-objects -v | grep '^count:'
count: 2

А если выполнить ту же проверку в репозитории с doomed.txt, количество объектов вообще не изменится. Результирующее дерево этого слияния побайтово совпадает с уже существующим объектом Git, поэтому записывать ничего нового не нужно.

Объекты, которые всё‑таки записываются, сразу становятся недостижимыми. Но «недостижимый» не значит «удалённый». Команда git gc их не удалит: по умолчанию параметр gc.pruneExpire установлен на две недели, поэтому при обычной сборке мусора такие объекты попадают в cruft pack (пакет недостижимых объектов).

Количество несжатых объектов снова снизится до нуля, но git cat-file -t <oid> по‑прежнему без проблем вернёт tree. Получается, что на современных версиях Git приведённый выше пример с count-objects создаст впечатление, будто объекты исчезли, хотя на самом деле они никуда не делись. (До версии 2.41, когда использование cruft pack стало поведением по умолчанию, объекты просто оставались несжатыми, а счётчик продолжал показывать два.)

Только git gc --prune=now действительно удалит эти объекты. Так что накладные расходы есть, хотя и небольшие. Со временем Git сам очистит ненужные объекты, но по собственному расписанию, а не тогда, когда вам захочется.

Кроме того, понадобится достаточно свежая версия Git. Оба параметра, --write-tree и --name-only, появились в Git 2.38, выпущенном 2 октября 2022 года. В более старых версиях доступен только прежний вариант merge-tree, который сейчас в документации находится в разделе «DEPRECATED DESCRIPTION» (описание устаревшего режима). Он выполняет примитивное слияние и, как прямо сказано в документации, не умеет «обрабатывать слияние содержимого отдельных файлов, обнаруживать переименования, корректно разрешать конфликты между файлами и каталогами и так далее».

Этот режим доступен и сегодня через --trivial-merge, но использовать его не стоит: он возвращает код завершения 0, даже если сообщает о конфликте. Если инструмент запускает внешнюю команду Git, не проверяя версию, на старом Git он не получит ошибку. Вместо этого он уверенно сообщит, что конфликтов нет, хотя при настоящем слиянии они возникнут. Поэтому проверяйте версию Git. Лучше вообще не показывать результат, чем показывать неверный.

Или можно обойтись без всего этого

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

Всё, что мы разобрали, полезно знать. Если вы понимаете, что Git может выполнить слияние в памяти без изменения рабочего дерева и что -X ours не спасёт от конфликта modify/delete, неприятных сюрпризов будет меньше. Но всё равно придётся помнить последовательность команд для оболочки. Причём именно в тот момент, когда вы пытаетесь принять совсем другое решение.

В GitDesktop перед выполнением слияния запускается проверка через merge‑base, а когда требуется, вызывается тот самый merge-tree. Интерфейс сразу показывает результат: ветка уже актуальна, возможно слияние перемоткой (fast‑forward), слияние пройдёт без конфликтов или возникнут конфликты с указанием конкретных файлов. Если вы измените стратегию разрешения конфликтов, предварительный просмотр запустится заново с выбранным параметром, вместо того чтобы гадать о результате за вас. Под капотом работают те же механизмы, которые мы только что разобрали. Просто запоминать команды больше не нужно.

Неважно, пользуетесь вы командной строкой или графическим клиентом. Главное, что ответ всегда был доступен. Чтобы узнать, к чему приведёт слияние, необязательно выполнять само слияние.

Проверка слияния веток помогает заранее обнаружить конфликты, но даже успешный merge не гарантирует, что изменения ничего не сломают. Чтобы находить проблемы до релиза и автоматизировать доставку кода, пригодятся тесты и CI/CD. Разобраться с этими задачами на практике можно на бесплатных вебинарах:

  • 5 ноября в 20:00. «Тесты в Go без боли: ловим баги раньше пользователей». Записаться

  • 16 ноября в 20:00. «GitlabCI + ArgoCD — сборка и доставка приложений, не покидая кластер». Записаться

Больше открытых уроков собрано в календаре мероприятий.

Комментарии (0)