Баг найден и исправлен в основной ветке разработки, но релиз 2.4 уже заморожен и живёт в отдельной ветке. Мержить всю ветку нельзя, там полмесяца чужих изменений. Решение давно известно: перенести один коммит с фиксом. Однако на практике cherry-pick в release-ветку редко проходит гладко с первой попытки: код за недели разошёлся, строки съехали, и Git честно встаёт с конфликтом. В статье разобран рабочий workflow бэкпорта от поиска нужного коммита до финального пуша, с флажком -x для прослеживаемости, с корректным продолжением после конфликта, с переносом диапазонов и с отдельным случаем мерж-коммитов, который многих ставит в тупик. Всё на реальных командах и выводах Git 2.43, без теории в вакууме.
Почему cherry-pick остаётся стандартом бэкпорта и откуда берутся конфликты
Backport через cherry-pick держится на простом свойстве: команда берёт diff одного коммита и применяет его как новый коммит в текущей ветке. Родитель у нового коммита другой, родословная веток не смешивается, история релиза остаётся чистой. Именно поэтому сопровождающие стабильных веток предпочитают cherry-pick даже при наличии удобных альтернатив вроде format-patch и git am.
Конфликт при переносе возникает не из-за ошибки, а из-за природы трёхстороннего слияния. Git сравнивает базовую версию файла (родитель переносимого коммита), версию фикса и текущую версию в release-ветке. Если область, которую трогал фикс, в release выглядит иначе, механическое слияние невозможно. Типичные причины: рефакторинг, который попал в основную ветку, но не в релиз; изменение отступов при переформатировании; переименование функции; соседний фикс, перенесённый раньше и изменивший контекст. Многие разработчики замечали закономерность: чем дольше release-ветка живёт без синхронизации, тем выше шанс, что десятый бэкпорт даст конфликт даже при чистых первых девяти.
Поиск и перенос одиночного коммита с фиксацией исходного SHA
Команда релиза обычно требует, чтобы в release-ветке было видно, откуда взялся каждый бэкпорт. Для этого служит флаг -x: Git дописывает в сообщение коммита строку со ссылкой на исходный хеш. Workflow выглядит так:
git checkout release/2.4
git log --oneline -5
a9f1c2d (HEAD -> release/2.4, origin/release/2.4) Bump version to 2.4.3
b77e01a Fix off by one in page iterator (#812)
5c3d9f0 Update packaged CA certificates
Разбор вывода построчно. Первая строка, команда checkout, переключает рабочее дерево на release/2.4. Дальше идёт лог: строка с HEAD показывает вершину ветки и где её видит удалённый репозиторий, стрелка HEAD -> release/2.4 означает, что локальная ветка совпадает с указателем. Ниже идут коммиты, понятно, что версия 2.4.3 уже поднята, значит бэкпорты лягут поверх.
Теперь сам перенос. Хеш фикса известен из основной ветки, скажем 4fa9b21:
git cherry-pick -x 4fa9b21
[release/2.4 81c3ad5] Fix null deref in session cache
Author: Olga Smirnova <Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript. >
Date: Tue Sep 30 14:12:05 2025 +0300
1 file changed, 7 insertions(+), 3 deletions(-)
Здесь Git сообщает новый хеш 81c3ad5 в release/2.4, сохранённого автора и дату исходного коммита, плюс статистику diffstat. Флаг -x добавил в сообщение строку "(cherry picked from commit 4fa9b21...)". По этой строке полгода спустя команда проверки релиза легко находит оригинал и убеждается, что фикс не потерян. Если конфликта нет, дело сделано, остаётся прогнать тесты и запушить. Весь нерв начинается, когда вместо красивого diffstat приходит CONFLICT.
Разрешение конфликта и правильный выбор между continue, abort и quit
Вот типичная картина после cherry-pick:
git cherry-pick -x 4fa9b21
Auto-merging src/session/cache.c
CONFLICT (content): Merge conflict in src/session/cache.c
error: could not apply 4fa9b21... Fix null deref in session cache
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>...", then run "git cherry-pick --continue".
Читаем построчно. Auto-merging означает, что Git пытался слить файл трёхсторонне. CONFLICT (content), внутри одних и тех же строк две разные правки, автоматика сдаётся. Строка error говорит, что коммит не применён, но процесс cherry-pick не провален, а поставлен на паузу: в .git создан каталог CHERRY_PICK_HEAD. Подсказка hint объясняет два пути: разрешить и continue или отменить через --abort.
Статус покажет детали:
git status
You are currently cherry-picking commit 4fa9b21.
(fix conflicts and run "git cherry-pick --continue")
Unmerged paths:
both modified: src/session/cache.c
"both modified" значит, что обе стороны меняли одно место. Открываем файл и видим маркеры:
<<<<<<< HEAD
entry = cache_lookup_lru(table, key);
=======
entry = cache_lookup_lru(table, key);
if (entry && entry->expires < now())
entry = NULL;
>>>>>>> 4fa9b21... Fix null deref in session cache
Верхняя секция, между <<<<<<< HEAD и =======, это версия release-ветки. Нижняя, до >>>>>>>, это переносимый фикс с проверкой срока годности записи. Правка ручная: оставить версию фикса, но убедиться, что имена вокруг совпадают с релизным кодом, потому что в основной ветке функция могла называться иначе. После правки маркеры удалить полностью, файл должен компилироваться до continue.
Затем стандартная тройка действий:
git add src/session/cache.c
git cherry-pick --continue
[release/2.4 9d04e1c] Fix null deref in session cache
Теперь про критические различия трёх флагов выхода из паузы. Последовательность выбора в реальном workflow такая:
- --continue завершает перенос после того как индекс чист, сообщение коммита откроется в редакторе с заготовкой и строкой -x;
- --abort полностью откатывает состояние к моменту до cherry-pick, включая сброс индекса и рабочих файлов, спасает когда конфликт оказался слишком глубоким и фикс проще переписать вручную;
- --quit останавливает секвенсор, убирая служебные файлы состояния, но оставляет рабочее дерево и индекс как есть, Git забудет, что был cherry-pick, а правки останутся болтаться незафиксированными.
Путаница между abort и quit стоит нервов: после quit при следующей попытке cherry-pick Git не ругнётся на незавершённую операцию, но дерево грязное, и разобраться что откуда взялось бывает сложно. Практическое правило простое: quit нужен редко, почти всегда выбор между continue и abort.
Полезно заранее задать $GIT_EDITOR или core.editor, чтобы при --continue не открывался неожиданный редактор:
git config --global core.editor "code --wait"
git config --global merge.conflictstyle zdiff3
Стиль zdiff3 (доступен с Git 2.35) показывает в конфликте ещё и базовую версию общего предка, что при бэкпортах бесценно: видно, как выглядел код до фикса, и понятно, какую именно строку он менял. Для старых версий подойдёт diff3.
Перенос диапазона коммитов и обработка мерж-коммитов через -m
Иногда фикс размазан на три коммита: сама правка, тест, документация. Тэпить их по одному утомительно, поэтому cherry-pick умеет диапазоны:
git cherry-pick -x 7c11aa0^..9e52bf3
Синтаксис A..B исключает A и включает B, поэтому крышка перед первым хешем обязательна, иначе начальный коммит не перенесётся. Это самая частая ошибка с диапазонами. Коммиты применяются в порядке следования в истории, и конфликт на втором из трёх не отменяет первый: исправляете, add, continue, и секвенсор едет дальше. Откатить половину диапазона помогает всё тот же --abort, вернёт ветку к точке до всей операции.
Отдельная боль, перенос мерж-коммита. Если фикс приехал в основную ветку пул-реквестом и забирать нужно именно итог слияния, обычный вызов даст ошибку:
git cherry-pick 3d8f77e
error: commit 3d8f77e is a merge but no -m option was given.
Мерж-коммит имеет двух родителей, и Git не знает, относительно кого считать diff. Флаг -m 1 указывает: считать основной линией первого родителя, то есть ветку, в которую вливали:
git cherry-pick -x -m 1 3d8f77e
Результатом будет один линейный коммит, содержащий суммарный diff всей ветки фикса. Минус очевиден: пошаговая история оригинальных коммитов теряется, в release остаётся комок. Часто честнее перенести составляющие коммиты диапазоном второго родителя. Узнать их помогает команда git log --oneline 3d8f77e^1..3d8f77e^2. Разбор: ^1 это первый родитель (куда мержили), ^2 второй (что мержили), диапазон между ними как раз коммиты фикса.
Стратегия theirs, автоматизация и контроль без повторных ручных правок
Бывают бэкпорты, где release-версия файла сильно устарела, а фикс адресный и заведомо правильный. Разбирать каждый маркер не хочется. На помощь приходит опция стратегии слияния:
git cherry-pick -x -X theirs 4fa9b21
Тонкость в направлении: для cherry-pick роли сторон перевёрнуты по сравнению с merge. "Текущей" стороной выступает ваша release-ветка, а theirs это переносимый коммит. Значит -X theirs при совпадающих конфликтных кусках возьмёт версию фикса. Обратный вариант -X ours оставит релизный код и по сути сделает перенос пустышку, что иногда используют чтобы отметить "фикс здесь не нужен" без конфликтов. Ещё пара полезных опций: -X ignore-space-change спасает когда ветки разошлись отступами, и -n (--no-commit) применяет изменения в индекс без коммита, удобно чтобы подчистить результат перед фиксацией.
Чтобы не править похожие конфликты при каждом бэкпорте, включают rerere:
git config --global rerere.enabled true
git config --global rerere.autoupdate true
Механизм запоминает, как был разрешён конфликт, и при повторении той же геометрии применяет решение сам; autoupdate ещё и добавляет файлы в индекс. На проекте с четырьмя живыми release-ветками это экономит заметное время.
Профилактика большинства проблем сводится к дисциплине: фикс делать маленьким и самодостаточным коммитом, без примеси рефакторинга; сначала чинить в основной ветке, потом бэкпортить, а не наоборот; после переноса запускать тесты именно на release-ветке, потому что фикс может ссылаться на функцию, которой там ещё нет; перед серией бэкпортов подтянуть свежую копию ветки через git fetch и git reset --hard origin/release/2.4, чтобы не тащить локальный мусор. Многие команды встраивают в процедуру релиза проверку скриптом: git cherry release/2.4 main показывает коммиты основной ветки, которых нет в релизе (строки с плюсом), и по ним легко свести список бэкпортов с трекером.
Типичные ошибки при бэкпорте и проверка результата перед пушем
Отдельно упомянем случай пустого переноса: если изменения фикса уже оказались в release-ветке, например через другой мерж, Git остановится с сообщением "The previous cherry-pick is now empty". Выход из ситуации либо git cherry-pick --skip, который просто перескакивает коммит и двигает секвенсор дальше, либо --abort, если вся идея переноса утратила смысл. Игнорировать пустой результат и давить --continue не получится: Git откажется создавать коммит без изменений без явного --allow-empty.
Первая частая ошибка, перенос коммита, который сам является бэкпортом, вместо оригинала. Строка -x в сообщении тогда ведёт в никуда по цепочке ссылок. Проверяйте git show SHA перед переносом и смотрите, нет ли в сообщении уже "cherry picked from". Вторая ошибка: забытый git add перед --continue, Git честно ответит "no changes added to commit" и останется на паузе. Третья: правка маркеров конфликта с остатком строки =======, компиляция падает с загадочной ошибкой синтаксиса. Четвёртая: пуш в защищённую ветку без прогона CI, и вся команда наблюдает красный релизный пайплайн.
Перед пушем стоит выполнить короткую проверку:
git log --oneline origin/release/2.4..
9d04e1c (HEAD -> release/2.4) Fix null deref in session cache
git show --stat HEAD
commit 9d04e1c...
(cherry picked from commit 4fa9b21...)
src/session/cache.c | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
Первая команда показывает, что локальная ветка ровно на один коммит впереди удалённой, ничего чужого не прихвачено. Вторая подтверждает наличие строки прослеживаемости и осмысленный diffstat. После прогона тестов пушим обычным git push origin release/2.4.
Техника cherry-pick обманчиво проста, пока не упирается в разошедшийся код. Реальный навык бэкпорта это не набор флагов, а понимание того, что Git делает под капотом: трёхстороннее слияние, пауза секвенсора, выбор родителя у мерж-коммита. С этой картиной в голове любой конфликт превращается из лотереи в понятную процедуру, и следующий багфикс доедет до релиза точно в срок.