vyvchy
    Теми розділу

    09 · Git і CI/CD

    Merge-конфлікти: причини й розв'язання

    Зміст

    Ви оновлюєте свою гілку з автотестами від main, тиснете git merge — і замість очікуваного «все злилося» отримуєте CONFLICT (content): Merge conflict in tests/login.spec.ts та зупинений процес. Гілка ні там ні сям, у файлі зʼявились дивні рядки з <<<<<<<, а колеги вже питають, коли буде готовий PR. Merge-конфлікт — не поломка й не помилка Git; це чесне зізнання інструмента: «двоє змінили те саме місце по-різному, і я не маю права вирішувати за вас».

    Для AQA це щоденна реальність, а не рідкість. Тестовий код живе в тому самому репозиторії, що й продукт (або в сусідньому активному репо), гілки довго висять у ревʼю, десятки людей правлять ті самі фікстури й page-object-и. Хто розуміє механізм конфлікту, розвʼязує його за хвилину й не боїться. Хто ні — або калічить чужі зміни наосліп, або в паніці видаляє гілку й переробляє все заново. На співбесідах це запитують майже завжди: «розкажіть, як ви вирішуєте merge-конфлікти» — і слабкого кандидата видно одразу, бо він плутає merge --abort з reset --hard і не може прочитати маркери.

    Чому Git узагалі впирається в конфлікт

    Щоб зрозуміти конфлікт, треба згадати, як Git зливає гілки (детальніше — у главі «Гілки та злиття»). Коли ви робите git merge, Git бере не дві версії, а три: вашу гілку (ours), гілку, яку вливаєте (theirs), і їхнього спільного предка — базу злиття (merge base). Це називають тристороннім злиттям (three-way merge).

    База — це той коміт, від якого гілки розійшлися. Порівнюючи кожну гілку з базою, Git бачить, хто що змінив, і питання «як злити» зводиться до простої логіки:

    • рядок змінили лише в одній гілці — Git бере цю зміну автоматично;
    • рядок змінили в обох гілках однаково — беремо будь-яку, вони збігаються;
    • рядок (або сусідні рядки) змінили в обох гілках по-різному — Git не має критерію, чия правка «правильніша», і зупиняється. Ось це і є конфлікт.

    різні правки

    правка лише в одній

    База злиття
    спільний предок

    Коміт у main

    Коміт у вашій гілці

    Три версії
    того самого рядка

    CONFLICT
    Git зупиняється

    Автозлиття

    різні правки

    правка лише в одній

    База злиття
    спільний предок

    Коміт у main

    Коміт у вашій гілці

    Три версії
    того самого рядка

    CONFLICT
    Git зупиняється

    Автозлиття

    Ключова думка: конфлікт — це не про «поганий Git», а про семантику. Git оперує рядками тексту, а не змістом коду. Він не знає, що ваша нова асерція й чужий рефакторинг того самого тесту можна поєднати — він бачить лише, що байти в одному місці розійшлися, і чесно передає рішення людині, яка розуміє зміст.

    Звідси відразу випливає, коли конфлікту не буде, хоча інтуїція підказує зворотне. Дві людини правлять один файл, але різні його ділянки (одна — початок, інша — кінець) — Git спокійно зіллє обидві правки. І навпаки: конфлікт можливий навіть без перетину рядок-у-рядок, якщо правки надто близькі — Git працює блоками (hunk) з невеликим контекстом навколо, і сусідні зміни можуть не влізти в один блок безболісно.

    Маркери конфлікту: як їх читати

    Коли Git наштовхується на конфлікт, він не відкочує все — навпаки, він залишає файл на диску, вставивши в проблемне місце обидві версії, розділені маркерами. Виглядає це так:

    export const LOGIN_TIMEOUT = 5000;
    
    <<<<<<< HEAD
    export const BASE_URL = 'https://staging.example.com';
    =======
    export const BASE_URL = 'https://qa.example.com';
    export const RETRIES = 2;
    >>>>>>> feature/qa-env

    Розберемо анатомію. Маркер <<<<<<< HEAD відкриває вашу версію (те, що зараз у гілці, куди ви зливаєте — HEAD). Далі йде ваш варіант рядків. Роздільник ======= відділяє вашу версію від чужої. Після нього — версія з гілки, яку ви вливаєте, а закриває блок >>>>>>> feature/qa-env з назвою тієї гілки (або хешем коміту при rebase/cherry-pick).

    Розвʼязати конфлікт означає видалити всі три маркери й лишити той текст, який справді має бути у фінальній версії. Це не механічний вибір «ліве або праве». У прикладі вище правильна відповідь — не «або staging, або qa», а осмислене рішення: можливо, треба взяти URL з чужої гілки, але зберегти й нову константу RETRIES, якої у вашій версії не було. Результат:

    export const LOGIN_TIMEOUT = 5000;
    
    export const BASE_URL = 'https://qa.example.com';
    export const RETRIES = 2;

    Найпідступніша пастка новачків — забути хоч один маркер. Рядок =======, що випадково лишився у файлі, — це синтаксична помилка в майже будь-якій мові, і білд впаде. Тому перше правило: після розвʼязання прогнати пошук <<<<<<<, =======, >>>>>>> по всіх файлах. Багато редакторів (VS Code) підсвічують конфлікти й дають кнопки «Accept Current / Incoming / Both», але кнопки — лише зручний спосіб редагувати текст; відповідальність за коректний результат усе одно на вас.

    Знайти всі конфліктні файли просто — git status покаже їх у секції «Unmerged paths», а git diff виведе саме конфліктні блоки.

    Розвʼязання: правка → add → commit

    Уся процедура вкладається в три кроки, і послідовність тут — не формальність, а сам протокол Git.

    Крок 1. Правка. Відкрийте кожен конфліктний файл і приведіть його до фінального стану — приберіть маркери, лишіть правильний зміст. Тут стає в пригоді знання самого коду: ви не «вгадуєте», а розумієте, що робили обидві сторони.

    Крок 2. git add. Коли файл доведений до ладу, ви робите git add <файл>. У контексті конфлікту add має особливий сенс: він не просто додає файл у staging (як у базовому циклі Git), а позначає конфлікт як розвʼязаний. Git знімає файл зі списку «Unmerged paths». Поки ви не додали всі конфліктні файли, злиття вважається незавершеним.

    Крок 3. git commit. Коли всі конфлікти позначені розвʼязаними, git commit створює коміт злиття (merge commit) — особливий коміт із двома батьками, який фіксує факт зʼєднання гілок. Git навіть підставить готове повідомлення на кшталт Merge branch 'feature/qa-env'. Усе, гілки злиті.

    Так

    Ні

    git merge → CONFLICT

    Правка: прибрати маркери
    у кожному файлі

    git add кожен
    розвʼязаний файл

    git status:
    лишились unmerged?

    git commit →
    merge commit

    Так

    Ні

    git merge → CONFLICT

    Правка: прибрати маркери
    у кожному файлі

    git add кожен
    розвʼязаний файл

    git status:
    лишились unmerged?

    git commit →
    merge commit

    Важливий нюанс: під час незавершеного злиття ви — у спеціальному стані «merging». Не варто в цей момент перемикати гілки чи починати нову роботу — спершу доведіть злиття до commit або відкотіть його (нижче). Перевірити перебіг злиття допомагає git diff (показує лишені конфлікти) і, для складних випадків, git checkout --ours <файл> / git checkout --theirs <файл>, які цілком беруть одну зі сторін, якщо ви точно вирішили не змішувати.

    merge --abort: кнопка «скасувати все»

    Іноді, побачивши масштаб конфлікту, найрозумніше — відступити й підготуватися краще, а не борсатися в маркерах. Для цього є git merge --abort: команда повністю відкочує незавершене злиття й повертає робочу директорію рівно в стан до git merge. Жодних напівзлитих файлів, жодних маркерів — наче ви й не починали.

    Це безпечна аварійна кнопка, і саме її треба назвати на співбесіді, коли питають «а якщо злиття пішло не так». Її часто плутають з git reset --hard, але різниця принципова: merge --abort знає про контекст злиття й акуратно скасовує саме його, тоді як reset --hard — грубий інструмент скидання стану, яким легко знести й ваші власні незакомічені зміни (детальніше — у главі «Скасування змін: reset, revert, stash»). merge --abort спрацює, лише поки злиття не закомічене; після commit відкочувати вже треба інакше (revert merge-коміту).

    Типовий здоровий сценарій: git merge main дав десяток конфліктів у файлах, яких ви навіть не торкались, — очевидно, ваша гілка сильно відстала. Робите merge --abort, спершу підтягуєте свіжий main, розумієте обсяг розбіжностей і лише тоді зливаєте свідомо, а не наосліп.

    Конфлікти в lock-файлах

    Окрема історія — конфлікти в lock-файлах менеджерів пакетів: package-lock.json, yarn.lock, pnpm-lock.yaml (у світі JS/TS, з якого AQA не вилазить). Це автогенеровані файли на тисячі рядків, що фіксують точні версії всього дерева залежностей. Коли дві гілки додали чи оновили різні пакети, їхні lock-файли майже гарантовано конфліктують — і на десятках рядків одразу.

    Головна помилка — намагатися злити такий файл руками. Це і безнадійно (ви не втримаєте в голові граф залежностей), і небезпечно: ручна правина легко зробить lock неконсистентним із package.json, і збірка почне поводитись непередбачувано. Правильний підхід — не редагувати lock, а перегенерувати його:

    # взяти package.json обох сторін узгодженим, потім:
    git checkout --theirs package-lock.json   # або --ours — байдуже, все одно перегенеруємо
    npm install                                # відновить lock із фактичного package.json
    git add package-lock.json

    Логіка проста: справжнє джерело істини — package.json (список бажаних залежностей), а lock лише його похідна. Спершу розвʼязуєте конфлікт у package.json осмислено (там рядків мало й вони зрозумілі), а потім даєте менеджеру пакетів заново вивести коректний lock однією командою. Сучасні менеджери це вміють: деякі версії npm/yarn зводять конфліктний lock автоматично при install. Головне — після цього переконатися, що встановлення чисте (npm ci на CI не впаде), і не комітити файл із маркерами.

    Конфлікти при merge проти rebase

    Той самий набір розбіжностей поводиться по-різному залежно від того, зливаєте ви гілки через merge чи rebase (сам rebase — тема окремої глави «Rebase, cherry-pick і переписування історії»). Різницю варто розуміти, бо це часте питання на співбесіді.

    При git merge конфлікт виникає один раз: Git намагається зʼєднати кінцеві стани двох гілок і, якщо є розбіжності, показує їх усі разом. Ви розвʼязуєте їх, робите один merge-коміт — і все.

    При git rebase Git переносить ваші коміти по одному на нову базу, ніби ви написали їх заново поверх свіжого main. Тому конфлікт може виникати на кожному вашому коміті окремо: розвʼязали перший — git rebase --continue, наткнулися на другий — знову правка й --continue, і так далі. Це може дратувати (той самий рядок «конфліктує» кілька разів, бо кожен ваш коміт його чіпав), зате історія виходить лінійною, без merge-коміту.

    АспектКонфлікт при mergeКонфлікт при rebase
    Скільки разів розвʼязуємоОдин раз, усе разомПотенційно на кожному коміті
    Команда продовжитиgit commitgit rebase --continue
    Аварійний вихідgit merge --abortgit rebase --abort
    Що з історієюЗʼявляється merge-комітІсторія лінійна, коміти з новими хешами
    Хто «ours», хто «theirs»ours — ваша гілкаours — база, theirs — ваш коміт (перевернуто!)

    Остання рядок — класична пастка. Під час rebase значення --ours і --theirs міняються місцями порівняно з merge, бо Git накладає ваші коміти на нову базу й «своєю» вважає базу. Не памʼятаєте — не покладайтесь на --ours/--theirs наосліп, дивіться на зміст.

    Обидва шляхи мають аварійну кнопку (--abort), і обидва не змушують розвʼязувати конфлікт «на живу» — завжди можна відступити. Вибір merge проти rebase — це вибір між правдивою історією зі слідами злиттів і чистою лінійною історією; конфлікти лише один із факторів у цьому холіварі.

    Профілактика: маленькі PR і часта синхронізація

    Найкращий конфлікт — той, якого не було. І тут працює не магія, а дисципліна.

    Маленькі, короткоживучі PR. Що довше гілка живе окремо від main, то далі розходяться історії й то болючіший конфлікт наприкінці. Гілка на один-два дні з десятком файлів зіллється майже завжди без проблем; гілка на три тижні з рефакторингом півпроєкту — гарантований бій. Тому маленькі PR — це не лише про зручність ревʼю (про це — у главі «Pull Request і стратегії гілкування»), а й пряма профілактика конфліктів.

    Часта синхронізація з базовою гілкою. Регулярно підтягуйте main у свою гілку (git merge main або git pull) — тоді ви ловите чужі зміни малими порціями, поки їх легко примирити, а не звалюєте все на фінал. Розвʼязати три рядки сьогодні простіше, ніж триста післязавтра.

    Домовленості про власність коду. Багато конфліктів народжуються від того, що двоє незалежно лізуть у той самий файл. Розумний поділ роботи, чіткі page-object-и й фікстури з одним «господарем», формат коду з автоформатером (щоб не конфліктувати на пробілах і лапках) — усе це зменшує площу зіткнення. Особливо це стосується автотестів: спільний config, спільні хелпери й централізовані локатори — гарячі точки, які варто чіпати обережно й узгоджено.

    Для повторюваних однакових конфліктів (буває при довгих rebase) у Git є git rerere (reuse recorded resolution) — механізм, що запамʼятовує ваше розвʼязання конкретного конфлікту й застосовує його автоматично наступного разу. Інструмент рівня middle, але знати про його існування корисно.

    Типові помилки

    • Виглядає як «Git усе зламав і треба видаляти гілку», а насправді — це штатний конфлікт, який розвʼязується за хвилину через правку → addcommit. Паніка й перестворення гілки — ознака нерозуміння, а не складності задачі.
    • Виглядає як «розвʼязав, бо натиснув Accept Incoming», а насправді — ви могли мовчки викинути власні потрібні зміни. Кнопки редактора обирають бік цілком; часто правильна відповідь — поєднати обидва боки, а не вибрати один.
    • Виглядає як «злив lock-файл руками — і він зелений», а насправді — lock міг стати неконсистентним із package.json, і збірка попливе непередбачувано. Lock треба перегенерувати менеджером, а не мерджити текстом.
    • Виглядає як «commit пройшов, конфлікт розвʼязано», а насправді — у якомусь файлі лишився маркер =======, білд упаде на CI. Завжди прогнати пошук маркерів перед комітом.
    • Виглядає як «щоб скасувати злиття, зроблю reset --hard», а насправді — для цього є безпечніший git merge --abort; reset --hard легко знесе й незакомічену роботу поруч.
    • Виглядає як «rebase конфліктує знову і знову — він зламаний», а насправді — rebase штатно розвʼязує конфлікти покомітно; після кожного треба git rebase --continue, а не починати спочатку.

    Підсумок

    • Конфлікт — це не помилка, а тристороннє злиття, у якому Git знайшов різні правки того самого місця й чесно передав рішення людині; він порівнює рядки, а не зміст.
    • Маркери <<<<<<< / ======= / >>>>>>> окреслюють вашу й чужу версії; розвʼязати — означає лишити правильний фінальний текст і прибрати всі маркери.
    • Протокол розвʼязання незмінний: правка файлів → git add (позначає конфлікт розвʼязаним) → git commit (створює merge-коміт).
    • git merge --abort безпечно відкочує незавершене злиття в стан до merge — це аварійна кнопка, не плутати з reset --hard.
    • Lock-файли не мерджать руками — перегенеровують менеджером пакетів із узгодженого package.json.
    • Merge конфліктує один раз і лишає merge-коміт; rebase — покомітно (--continue після кожного) з лінійною історією та переверненими ours/theirs.
    • Найдешевший конфлікт — відвернений: маленькі PR і часта синхронізація з базовою гілкою.

    Що питають на співбесіді

    • «Як ви розвʼязуєте merge-конфлікт?» — найчастіше питання розділу. Сильна відповідь: назвати причину (різні правки того самого місця при тристоронньому злитті), прочитати маркери, описати цикл правка → addcommit і згадати merge --abort як вихід. Інтервʼюер перевіряє, що ви розумієте механізм, а не завчили кроки.
    • «Чому взагалі виникає конфлікт і коли його не буде?» — тут дивляться, чи розумієте ви, що Git працює з рядками й що правки в різних місцях одного файлу зливаються без конфлікту. Плутанина «конфлікт = будь-яка правка того самого файлу» — червоний прапорець.
    • «Merge чи rebase, і як це впливає на конфлікти?» — очікують розуміння, що rebase розвʼязує покомітно й дає лінійну історію, а merge — разом і з merge-комітом; бонус — згадати перевернуті ours/theirs і золоте правило не робити rebase спільних гілок.
    • «Що робити з конфліктом у package-lock.json?» — коротка й точна відповідь «не мерджити руками, перегенерувати через install» одразу вирізняє того, хто справді працював у команді.
    • «Як зробити так, щоб конфліктів було менше?» — маленькі PR, часта синхронізація з main, автоформатер, поділ власності над файлами. Це питання про інженерну культуру, не лише про Git.

    Джерела