Skip to main content

0001. Git и большой рефакторинг в monorepo. Почему merge стирает feature-ветку и как этого избежать

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

Post cover where you see big Git delta

Введение - для своих

На текущем проекте следующая ситуация

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

В чём проблема

  • недавно наш сокамерник влил в основную ветку коктейл из трёх составляющих
NX + RsPack + переработка_файловой_системы

Groot talking about NX, RsPack and reworked file structure

Рецепт успеха

  • проигнорировать сообщение
  • ни до ни после его изменений, не актуализировать крупные feature-ветки
  • смотреть как основная main ветка уезжает вперёд
  • дотянуть до последнего

Каждый из нас подумал "Я самый умный и я это докажу при следующем мёрдже..."

И вот в один день на дейлике слышу как братик фронтендер второй день подряд докладывает

  • "Сижу подливаю feature-ветку в основную ветку" (Ветку которую в обычном случае ты за 10-15 минут подмерджишь с решением конфликтов. Ну 30, хорошо)
  • Я дико удивляюсь этому. Мы начинаем проговаривать и выясняется ...
warning

При попытке подлить feature-ветку в нашу основную ветку мы теряем всю фичу. И инженер сидит вручную переносит фичу. Фичу где изменения по всей файловой системе. А он подливает ее в ветку с радикально измененной файловой структурой. Где еще много перекрестных изменений. И много файлов уже переработано или не существует...

И теперь мой контекст

  • конец года в энтерпрайзе
  • только прошла волна сокращений и мы её с горем пополам прожили
  • идёт активная трансформация нашего проекта, и техническая, и организационная
  • мы много раз ехали вправо
  • из-за этого было множество корректирующих обратных связей и двусторонний подрыв доверия
  • и тут я понимаю, что 20+ фиче веток нужно прогнать через процесс ручного мёрджа
  • а команда вымотана

И тут ко мне приходит идея...

Superman and year goals

В общем, надо искать алгоритм решения который можно расшарить. Что-то большее, чем удача


Введение. Теперь по теме

Я часто слышу от умников, что

Fuck off to all smart people

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

  • а какой рецепт, чтобы его передать
  • и как такого больше не допускать. То есть вынести из этого уроки

Ну и я обращаюсь к единственному источнику правды - исходники

Git repo overview

Кто-бы что тебе не рассказал и сколько бы ты не прочитал про 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

При попытке 👇

Meme about merging feature into main branch

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

tip

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 - дефолтный алгоритм, который

2.3. Rename-детекция - diffcore-rename

Самое важное - Git не хранит в истории операцию rename. Он хранит просто "в этом коммите один файл исчез, другой появился". А всё, что выглядит как rename, - результат эвристики. Это подчёркивается и в доках, и в хороших объяснениях про rename’ы

Meme about heuristics is everywhere

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

React heuristics

Мы отвлеклись. Так вот технически

  • 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 estimate similarity fragment

Если похожесть ниже порога (например, Андрюха сильно переписал файл при переносе) - 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 - старого пути нет, новый путь есть (и помни - Git stateless)
  • В дереве theirs - старый путь есть, новый нет

Дальше

  1. diff base -> ours говорит - файл либо удалён, либо переименован
  2. diff base -> theirs говорит - файл изменён
  3. если 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-детекция - эвристика, которая может ошибаться при большом объёме изменений

Если ты одним махом мержишь кусок истории, где

  • сотни/тысячи файлов переехали
  • изменилось содержимое
  • изменилась структура директорий

то

  1. матрица похожести (diff_score в diffcore-rename) становится огромной
  2. порог похожести может не выполняться для многих пар
  3. merge-ort получает слабый сигнал о rename’ах и начинает чаще трактовать изменения как delete + add, а не rename

Результат - Git видит твой код не как эволюцию прежних файлов, а как

  • попытка вернуть старые пути/старые файлы поверх новой структруры. И честно спрашивает: какую версию ты хочешь оставить?
  • попытка добавить новые файлы (и он их и добавляет, для тебя в старые пути а для себя в новые. Про старые он ничего не помнит)

4. Наш алгоритм

Это приём с пошаговым прогоном коммитов рефактора - по сути ручное расстилание красной дорожки для merge-ort - мы уменьшаем размер диффа, помогаем rename-детекции сработать и вручную дообучаем Git в каждом конфликтном коммите

Теперь к приятной части - что мы сделали и почему это работает

По сути мы реализовали ручной chunked merge через проблемный интервал истории

  1. Вернули main к точке до большого рефактора (локально разумеется)
# допустим, нужный SHA до рефактора: a1b2c3d4
git log --oneline main

git reset --hard a1b2c3d4

Актуализировали фичу от "безопасного состояния" main ветки

git checkout feature_old/*
git merge main
  1. Цикл

Вернулись в 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

Наш алгоритм делает две вещи

  1. Меньше пар для сравнения за раз. Вместо одной гигантской матрицы подобий (rename_src_nr × rename_dst_nr) мы прогоняем много маленьких - по одному коммиту. Это уменьшает количество неоднозначных случаев, где Git не уверен в rename/move

  2. Более устойчивое содержимое. Если каждый коммит рефактора относительно небольшой, вероятность, что 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:

    • merge-ort.c - реализация стратегии ORT, обработка merge_side, rename_info, conflict_info и логики вокруг rename’ов (исходник)
    • diffcore-rename.c - алгоритм подсчёта похожести файлов, таблицы rename_src/rename_dst, directory rename-логика (исходник)
  • Хорошие объяснения и статьи:


Выводы

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