0001. Git и большой рефакторинг в monorepo. Почему merge стирает feature-ветку и как этого избежать
Как устроен merge-ort и rename detection в Git, почему после глобального рефакторинга пропадают фичи и как по шагам протащить feature-ветку через зону рефактора без потери кода

Введение - для своих
На текущем проекте следующая ситуация
- живём в модульном монолите (50% легаси, 50% модульности)
- около 400к тысяч строк кода
- одновременно в один репозиторий пишут 6 Front-End разработчиков (фичи часто пересекаются)
- идём в сервисность (микрофронты на
RsPack). Почти уже доделали
В чём проблема
- недавно наш сокамерник влил в основную ветку коктейл из трёх составляющих
NX + RsPack + переработка_файловой_системы

Рецепт успеха
- проигнорировать сообщение
- ни до ни после его изменений, не актуализировать крупные
feature-ветки - смотреть как основная
mainветка уезжает вперёд - дотянуть до последнего
Каждый из нас подумал "Я самый умный и я это докажу при следующем мёрдже..."
И вот в один день на дейлике слышу как братик фронтендер второй день подряд докладывает
- "Сижу подливаю
feature-веткув основную ветку" (Ветку которую в обычном случае ты за 10-15 минут подмерджишь с решением конфликтов. Ну 30, хорошо) - Я дико удивляюсь этому. Мы начинаем проговаривать и выясняется ...
При попытке подлить feature-ветку в нашу основную ветку мы теряем всю фичу. И инженер сидит вручную переносит фичу. Фичу где изменения по всей файловой системе. А он подливает ее в ветку с радикально измененной файловой структурой. Где еще много перекрестных изменений. И много файлов уже переработано или не существует...
И теперь мой контекст
- конец года в энтерпрайзе
- только прошла
волна сокращенийи мы её с горем пополам прожили - идёт активная трансформация нашего проекта, и техническая, и организационная
- мы много раз ехали вправо
- из-за этого было множество корректирующих обратных связей и двусторонний подрыв доверия
- и тут я понимаю, что 20+ фиче веток нужно прогнать через процесс ручного мёрджа
- а команда вымотана
И тут ко мне приходит идея...

В общем, надо искать алгоритм решения который можно расшарить. Что-то большее, чем удача
Введение. Теперь по теме
Я часто слышу от умников, что
- Git book - это прекрасно
- Документация по Git - роскошно
- а Курсы по git ещё лучше

Вцелом с Git у всех, что касается базовых ситуаций дела обстоят как у меня - +- справляюсь. Но тут мало времени и надо понять не как пофиксить один ПР
- а какой рецепт, чтобы его передать
- и как такого больше не допускать. То есть вынести из этого уроки
Ну и я обращаюсь к единственному источнику правды - исходники

Кто-бы что тебе не рассказал и сколько бы ты не прочитал про Redux-Thunk, один взгляд на исходник тебе расскажет больше чем любой другой материал
Итого
git clone https://github.com/git/git.git- открываю
IDE-шку - начинаю гуглить
merge,diffи тд. Нахожу участки кода ответственные за диффинг и слияния. Изучаю код и комментарии в коде - сформировал понимание того как
Gitпринимает решения - наклёвывается алгоритм
- приезжаю в офис и рассказываю мужикам и Маше, что у меня есть серебрянная пуля
- создаю конфу, собираю фронтовиков, мы обогощаем алгоритм здравым смыслом
- стрим на несколько часов
- ...
Как большой рефактор ломает merge и почему Git стирает фичу
Git не стирает фичу - он честно делает трёхсторонний merge поверх жёсткого рефактора (rename + перемещения файлов) и по своим эвристикам решает, что новая файловая структура главной ветки важнее, а твоя фича выглядит как попытка вернуть старый мир о котором он уже давно забыл
1. Что произошло
У нас
main- основная ветка с фичами (на самом деле она называетсяtech-support/release-train. Причина - я гений нэйминга)feature/microfrontend- ветка с радикальным изменением файловой структурыfeature/microfrontendбыла влита вmain- Есть куча фичеветок, которые ответвились до вливания
feature/microfrontend
При попытке 👇

Git как будто затирает нашу фичу: мы видим
- кучу удалений/перезаписей
IDEдаже не помогает разрулить конфликты в сторонуmain- и в результате наш код исчезает либо складируется в заново сформированные участки файловой структуры (на основе старой схемы)
1.1. Граф истории до и после рефактора
feature_old/*- любая фича, ответвившаяся отmainдо радикальных измененийfeature/microfrontend- меняет структуру проекта и затем вносит эти изменения вmain- вся дальнейшая работа в ветке
mainидёт уже в новой файловой структуре
Любая старая ветка (feature_old/*) логически существует в старой структуре, а main - в новой. Git пытается примирить два разных мира
2. Как Git реально делает merge
2.1. Трёхсторонний merge: base / ours / theirs
git merge использует трёхсторонний merge: это не магия и не фильм для взрослых, а вполне конкретный алгоритм. В мане git-merge прямо сказано, что Git
Incorporates changes from the named commits (since the time their histories diverged) into the current branch
Концептуально
base- общий предок веток (общая точка разветвления)ours- текущая ветка (куда мержим)theirs- ветка, которую вливаем
У нас при merge старой фичи в новый main
base- коммит до большого рефактораours-mainпосле рефактораtheirs- фича, где файлы ещё в старых путях
Псевдокод трёхстороннего merge
struct в C - это описанный набор полей, как
- type
{ ... }/interfaceв TypeScript - или
DTO/recordна бэкенде
только он ещё жёстко задаёт, как именно эти данные лежат в памяти
struct Version {
const char *path;
const char *content;
};
struct MergeInput {
struct Version base;
struct Version ours;
struct Version theirs;
};
enum MergeResultKind {
CLEAN,
CONFLICT_CONTENT,
CONFLICT_RENAME,
CONFLICT_DELETE,
};
struct MergeResult {
enum MergeResultKind kind;
struct Version final;
};
struct MergeResult merge_file(struct MergeInput in) {
/**
* 1. нет изменений vs base -> берём изменённый
* 2. изменения только с одной стороны -> берём её
* 3. обе стороны меняли -> либо трёхсторонний merge, либо конфликт
* 4. если одна сторона удаляет, а другая меняет -> modify/delete конфликт
*/
}
В реальности это гораздо сложнее, но суть такая
2.2. ORT - дефолтный merge-алгоритм
Начиная с Git 2.34, дефолтная стратегия merge - ort (Ostensibly Recursive’s Twin). (Stack Overflow)
В заголовке merge-ort.c прямо написано
/* "Ostensibly Recursive's Twin" merge strategy, or "ort" for short.
* Meant as a drop-in replacement for the "recursive" merge strategy.
*/
Официальная документация по merge-стратегиям подтверждает, что ort - дефолтный алгоритм, который
- использует 3-way merge
- умеет определять rename’ы
- оптимизирует память и скорость
2.3. Rename-детекция - diffcore-rename
Самое важное - Git не хранит в истории операцию rename. Он хранит просто "в этом коммите один файл исчез, другой появился". А всё, что выглядит как rename, - результат эвристики. Это подчёркивается и в доках, и в хороших объяснениях про rename’ы

Там где заканчивается машина, начинается человек. Любой фронтендер это знает непонаслышке (попробуй повторять это слово много раз пока оно не потеряет смысл)

Мы отвлеклись. Так вот технически
Gitсначала строит список парстарый файл -> новый файлвdiffcore-rename. (мана)- Для этого есть модуль
diffcore-rename.c. (исходник)
Часть таблицы rename-назначений
static struct diff_rename_dst {
struct diff_filepair *p;
struct diff_filespec *filespec_to_free;
int is_rename; /* 0 -> create; 1 -> rename/copy */
} *rename_dst;
static int rename_dst_nr, rename_dst_alloc;
static int add_rename_dst(struct diff_filepair *p)
{
ALLOC_GROW(rename_dst, rename_dst_nr + 1, rename_dst_alloc);
rename_dst[rename_dst_nr].p = p;
rename_dst[rename_dst_nr].filespec_to_free = NULL;
rename_dst[rename_dst_nr].is_rename = 0;
rename_dst_nr++;
return 0;
}
А сердцевина rename-эвристики - функция estimate_similarity(...), которая
- сравнивает размеры файлов
- при необходимости загружает содержимое
- считает, какой процент байтов переехал из старого файла в новый
- возвращает
scoreот 0 доMAX_SCORE

Если похожесть ниже порога (например, Андрюха сильно переписал файл при переносе) - Git не считает это rename’ом, а видит удаление + создание
Официальная документация gitdiffcore описывает, как diffcore-rename используется и какие опции (например, -M и --find-renames=<n>) влияют на порог (мана)
2.4. Directory rename detection
Когда файлов много и ты переименовал не только файлы, но и целые директории, включается directory rename detection
- на базе результатов
diffcore-renameстроится логика "эта папка переехала туда-то" - используется в
merge-ortи ранее вmerge-recursive(мана)
Это важно именно для случаев вроде нашего: "весь фронт переехал в новую иерархию модулей и нам нужно вызывать Андрюху в офис"
3. Почему Git стирает фичу при большом рефакторе
Теперь сложим всё вместе
3.1. Типичный сценарий конфликта
Пусть был файл
- в
base-src/old/path/PashaSlomalDevStand.tsx - в
ours(mainпосле рефактора) - этот файл переехал и переименовался вpackages/ui/PashaSlomalDevStand.tsxи ещё переписан - в
theirs(старая фича) - ты продолжаешь менятьsrc/old/path/PashaSlomalDevStand.tsx.
Git видит
- В дереве
ours- старого пути нет, новый путь есть (и помни -Gitstateless) - В дереве
theirs- старый путь есть, новый нет
Дальше
- diff
base -> oursговорит - файл либо удалён, либо переименован - diff
base -> theirsговорит - файл изменён - если rename-эвристика не уверена, что
src/old/path/PashaSlomalDevStand.tsx->packages/ui/PashaSlomalDevStand.tsx- тот же файл (слишком сильные изменения),Gitтрактует это как
- там удалили старый файл и создали новый
- тут изменили старый
Это даёт modify/delete-конфликт. merge-код (merge-ort) помечает такие ситуации как CONFLICT_MODIFY_DELETE
Если в IDE или руками ты жмёшь "оставить их версию"/"оставить нашу" не глядя - ты фактически
- либо откатываешь рефактор в пользу старой структуры
- либо выбрасываешь свои изменения в старых файлах и принимаешь только новый путь
И тут самое интересное, Git stateless и он уже забыл, что была старая файловая структура. И теперь ты будешь видеть файлы которые расположены в отдельном фрагменте файловой системы по старой схеме
Субъективно это выглядит как "git стёр фичу", или "Андрюха заебал"
3.2. Почему один гигантский merge особенно адский
Официальная мана и кучи статей подчёркивают, что rename-детекция - эвристика, которая может ошибаться при большом объёме изменений
Если ты одним махом мержишь кусок истории, где
- сотни/тысячи файлов переехали
- изменилось содержимое
- изменилась структура директорий
то
- матрица похожести (
diff_scoreвdiffcore-rename) становится огромной - порог похожести может не выполняться для многих пар
merge-ortполучает слабый сигнал о rename’ах и начинает чаще трактовать изменения какdelete + add, а неrename
Результат - Git видит твой код не как эволюцию прежних файлов, а как
- попытка вернуть старые пути/старые файлы поверх новой структруры. И честно спрашивает: какую версию ты хочешь оставить?
- попытка добавить новые файлы (и он их и добавляет, для тебя в старые пути а для себя в новые. Про старые он ничего не помнит)
4. Наш алгоритм
Это приём с пошаговым прогоном коммитов рефактора - по сути ручное расстилание красной дорожки для merge-ort - мы уменьшаем размер диффа, помогаем rename-детекции сработать и вручную дообучаем Git в каждом конфликтном коммите
Теперь к приятной части - что мы сделали и почему это работает
По сути мы реализовали ручной chunked merge через проблемный интервал истории
- Вернули
mainк точке до большого рефактора (локально разумеется)
# допустим, нужный SHA до рефактора: a1b2c3d4
git log --oneline main
git reset --hard a1b2c3d4
Актуализировали фичу от "безопасного состояния" main ветки
git checkout feature_old/*
git merge main
- Цикл
Вернулись в main
git сheckout main
# подтянули все с origin
git pull
# нашли hash следующего коммита
git reset --hard +1_коммит_после_a1b2c3d4
Актуализировали фичу от следующего "состояния" main ветки (тут уже есть элемент проблемной зоны)
git checkout feature_old/*
git merge main
И так дальше пока не прошли проблемную зону. В нашем случае было 4 коммита и уже после 2-х первых мы следующие 2 подлили пачкой (Git уже понимал что делать)
На каждом шаге
- у нас маленький дифф -> rename-эвристика Git’а работает лучше
- конфликты локализованы -> мы руками правильно совмещаем изменения фичи с конкретным шагом рефактора
- Git "запоминает" renames и состояние в истории
После зоны рефактора - обычный merge хвоста
# назад в main
git checkout main
# создаём копию
git checkout -b main_with_feature
# мерджим туда фичу
git merge feature_old/*
# пушим
git push -u origin main_with_feature
# Создаём ПР и наслаждаемся своей гениальностью
Оставшаяся дельта обычно
- меньше по объёму
- существенно менее структурно разрушительна (там уже бизнес-логика, а не переезд города)
mergeпроходит с минимумом боли
5. Почему наш алгоритм работает с точки зрения исходников Git
5.1. Мы уменьшаем энтропию для diffcore-rename
В доке gitdiffcore говорится, что rename-детекция - это отдельный этап над diff’ом, который можно включать/настраивать опциями и который по факту обрабатывает пары файлов, оценивая похожесть и заполняя структуры вроде diff_rename_dst и diff_rename_src
Наш алгоритм делает две вещи
-
Меньше пар для сравнения за раз. Вместо одной гигантской матрицы подобий (
rename_src_nr × rename_dst_nr) мы прогоняем много маленьких - по одному коммиту. Это уменьшает количество неоднозначных случаев, гдеGitне уверен вrename/move -
Более устойчивое содержимое. Если каждый коммит рефактора относительно небольшой, вероятность, что
estimate_similarity()скажет "это всё ещё тот же файл", повышается - размеры и содержимое отличаются не так драматически
5.2. merge-ort может эффективнее использовать информацию о rename’ах
merge-ort активно использует данные о rename’ах и directory rename’ах, чтобы
- строить карту путей (
paths) - определять, когда директорию можно "тривиально" смержить без рекурсии
- обрабатывать rename/delete, rename/rename, file/dir конфликты
Внутри merge-ort.c есть
struct rename_info, где хранятся данные о переименованиях и директориях- флаги оптимизаций вроде
redo_after_renames, которые позволяют заново пройти дерево merge после того, какrename’ы определены
Наш пошаговый подход
- даёт
merge-алгоритму более чистую картинку мира на каждом шаге - позволяет
rename_infoаккуратно накапливать пары переименований - уменьшает случаи, когда
mergeпопадает в тяжёлыеCONFLICT_RENAME_*/CONFLICT_MODIFY_DELETE
Грубо говоря, мы не кидаем в merge-ort бетонную плиту, а подаём в неё по кирпичику. И Андрюха цел и я Дартаньян
6. Как делать большие рефакторы без массовой боли
1. Отдельные коммиты для rename’ов
По возможности делай коммит, где:
- только переименования и перемещения файлов
- минимум изменений логики Это рекомендация встречается и в статьях по Git rename’ам, и в практиках крупных команд
2. Короткий freeze на main
Перед мержем большого рефактора
- остановить вливания фич в main на небольшой период
- влить рефактор
- попросить все живые фичи обновиться от нового main (через
rebaseилиmerge)
3. Документировать
Прямо в README рассказать про зону турбулентности
- указать диапазон коммитов рефактора
- описать рекомендованный сценарий обновления фич (вроде того, что ты уже читаешь)
7. Почитать
-
Официальная документация Git:
git-merge- описание трёхстороннего merge и стратегий (мана)merge-strategies- подробности проortиrecursive(мана)gitdiffcore- внутренности diffcore и rename-детекции(мана)directory-rename-detectionиremembering-renames- технические заметки о том, как Git детектит переименования директорий и переиспользует информацию о rename’ах при rebase/cherry-pick(мана)
-
Исходники Git:
-
Хорошие объяснения и статьи:
Выводы
- приоретизация. Ты не обязан успеть всё
- если Андрюха пишет сообщение в котором есть слова -
Внимание,Я тут порефачил,Пожалуйста имейте ввиду,Наша система теперь обоброжается немного по-другому- нужно собрать встречу и попросить его рассказать более детально про масштаб инноваций - любишь зэпку лутать, люби и вручную помёрджить