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 не має критерію, чия правка «правильніша», і зупиняється. Ось це і є конфлікт.
Ключова думка: конфлікт — це не про «поганий 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'. Усе, гілки злиті.
Важливий нюанс: під час незавершеного злиття ви — у спеціальному стані «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 commit | git rebase --continue |
| Аварійний вихід | git merge --abort | git 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 усе зламав і треба видаляти гілку», а насправді — це штатний конфлікт, який розвʼязується за хвилину через правку →
add→commit. Паніка й перестворення гілки — ознака нерозуміння, а не складності задачі. - Виглядає як «розвʼязав, бо натиснув 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-конфлікт?» — найчастіше питання розділу. Сильна відповідь: назвати причину (різні правки того самого місця при тристоронньому злитті), прочитати маркери, описати цикл правка →
add→commitі згадатиmerge --abortяк вихід. Інтервʼюер перевіряє, що ви розумієте механізм, а не завчили кроки. - «Чому взагалі виникає конфлікт і коли його не буде?» — тут дивляться, чи розумієте ви, що Git працює з рядками й що правки в різних місцях одного файлу зливаються без конфлікту. Плутанина «конфлікт = будь-яка правка того самого файлу» — червоний прапорець.
- «Merge чи rebase, і як це впливає на конфлікти?» — очікують розуміння, що rebase розвʼязує покомітно й дає лінійну історію, а merge — разом і з merge-комітом; бонус — згадати перевернуті
ours/theirsі золоте правило не робити rebase спільних гілок. - «Що робити з конфліктом у package-lock.json?» — коротка й точна відповідь «не мерджити руками, перегенерувати через install» одразу вирізняє того, хто справді працював у команді.
- «Як зробити так, щоб конфліктів було менше?» — маленькі PR, часта синхронізація з
main, автоформатер, поділ власності над файлами. Це питання про інженерну культуру, не лише про Git.
Джерела
- Pro Git — Basic Branching and Merging — канонічне пояснення тристороннього злиття та розвʼязання конфліктів.
- git-merge — офіційна документація — опис
--abort, стратегій злиття й поведінки при конфлікті. - git-rerere — reuse recorded resolution — автоматичне повторне застосування вже зроблених розвʼязань.
- GitHub Docs — Resolving a merge conflict — покроково для типового PR-флоу.
- ISTQB Certified Tester Foundation Level (CTFL) v4.0 — керування конфігурацією (configuration management) як контекст роботи з версіями в тестуванні.
Що таке merge-конфлікт і чому Git на ньому зупиняється?
Конфлікт — це ситуація, коли дві гілки по-різному змінили те саме місце в файлі, і Git відмовляється вгадувати, чия правка головніша. Це не поломка інструмента й не помилка: Git чесно каже, що не має критерію обрати між двома варіантами, і передає рішення людині. Механізм у тому, що Git порівнює текст рядок за рядком, а не розуміє зміст коду, тож будь-яке розходження байтів в одній ділянці для нього рівнозначне. Практично для AQA це щоденна річ: тести живуть у тому ж репозиторії, що й продукт, гілки довго висять у ревʼю, і десятки людей чіпають ті самі фікстури та page-object-и. Хто знає, що конфлікт штатний, розвʼязує його за хвилину; хто ні — панікує або мовчки псує чужі зміни.
Що таке тристороннє злиття і до чого тут база злиття?
Тристороннє злиття (three-way merge) — це спосіб, у який Git зʼєднує гілки, спираючись не на дві, а на три версії файлу. Крім вашої гілки та тієї, яку ви вливаєте, він бере ще їхнього спільного предка — коміт, від якого гілки розійшлися; це і є база злиття (merge base). Порівнюючи кожну гілку з базою, Git бачить, хто саме що змінив відносно спільної точки, і завдяки цьому вирішує більшість випадків автоматично. Якщо рядок правила лише одна сторона — правка береться без питань; якщо обидві змінили його однаково — беруться збіжні варіанти; якщо ж обидві правили той самий рядок по-різному — критерію немає, і Git зупиняється. Без бази він не відрізнив би «додав новий рядок» від «видалив чужий», тож саме спільний предок робить злиття осмисленим.
Дві людини правили один файл. Чому конфлікту може й не бути?
Бо Git конфліктує не за файлом, а за конкретними ділянками рядків. Якщо одна людина чіпала початок файлу, а інша — кінець, правки не перетинаються, і Git спокійно зіллє обидві без жодного втручання. Поширена помилка новачка — думати «ми обоє редагували один файл, отже, буде конфлікт»; це червоний прапорець на співбесіді, бо показує нерозуміння механізму. Водночас працює й зворотне: конфлікт можливий навіть без прямого перетину рядок-у-рядок, якщо правки надто близькі. Git оперує блоками (hunk) з невеликим контекстом навколо зміни, і сусідні правки іноді не вміщаються в один блок безболісно. Тож правильна модель — «конфлікт там, де розійшлися близькі рядки», а не «конфлікт там, де спільний файл».
Як читати маркери конфлікту у файлі?
Коли Git наштовхується на конфлікт, він не відкочує роботу, а лишає файл на диску, вставивши в спірне місце обидві версії між трьома маркерами. <<<<<<< HEAD відкриває вашу версію — те, що зараз у гілці, куди ви зливаєте. Рядок ======= розділяє два варіанти. Нижче йде версія з гілки, яку ви вливаєте, а закриває блок >>>>>>> з назвою цієї гілки (або з хешем коміту, якщо це rebase чи cherry-pick). Розвʼязати конфлікт означає прибрати всі три маркери й лишити той текст, який справді має бути у фінальній версії. Ключове: це не механічний вибір «ліве або праве» — часто правильна відповідь поєднує обидва боки, наприклад бере URL із чужої гілки, але зберігає нову константу, якої у вашій версії не було.
Опишіть повний цикл розвʼязання merge-конфлікту.
Він складається з трьох кроків, і їхня послідовність — не формальність, а протокол Git. Спершу правка: відкриваєте кожен конфліктний файл, прибираєте маркери й доводите вміст до правильного фінального стану, спираючись на розуміння коду обох сторін. Далі git add кожного розвʼязаного файлу — саме він знімає файл зі списку невзгоджених. Нарешті git commit, який фіксує злиття. Знайти всі проблемні файли просто: git status показує їх у секції «Unmerged paths», а git diff виводить самі конфліктні блоки. Поки хоч один файл лишається невзгодженим, злиття вважається незавершеним, і комітити ще рано. Сильний кандидат називає причину конфлікту, читає маркери й описує саме цей цикл, а не завчені команди.
Який особливий сенс має git add під час розвʼязання конфлікту?
У звичайному циклі git add просто кладе зміни у staging, але в контексті конфлікту він додатково означає «цей файл я розвʼязав». Git прибирає файл зі списку «Unmerged paths» і починає вважати його готовим до коміту. Тобто add тут — це сигнал інструменту, що ви завершили примирення саме цього файлу, а не лише технічна підготовка до коміту. Практичний наслідок: доки ви не додали всі конфліктні файли, Git не дасть завершити злиття штатним комітом і триматиме вас у стані «merging». Тому корисна звичка — після кожної правки прогнати git status і переконатися, що список невзгоджених файлів справді порожніє, а не лишає забутий файл.
Що створює git commit після розвʼязання і чим цей коміт особливий?
Коли всі конфлікти позначені розвʼязаними, git commit створює коміт злиття (merge commit) — особливий коміт із двома батьками, який фіксує сам факт зʼєднання двох гілок. Git навіть підставляє готове повідомлення на кшталт Merge branch 'feature/qa-env', бо знає контекст. Два батьки — це те, що відрізняє merge-коміт від звичайного: за ним видно, що в цій точці історія двох ліній зійшлася. Саме тому після нього відкотити злиття вже не можна через merge --abort — воно завершене, і для скасування потрібен revert цього merge-коміту. Для AQA це важливо розуміти, коли розбираєш історію гілки з тестами: merge-коміт лишає слід злиття, тоді як rebase такого сліду не залишає.
Чим git merge --abort відрізняється від git reset --hard?
git merge --abort — це аварійна кнопка, що повністю відкочує незавершене злиття й повертає робочу директорію рівно в стан до git merge: жодних напівзлитих файлів і маркерів, наче ви й не починали. Саме її треба назвати на співбесіді у відповідь на «а якщо злиття пішло не так». Різниця з reset --hard принципова: merge --abort знає про контекст злиття й акуратно скасовує саме його, тоді як reset --hard — грубе скидання стану, яким легко знести й ваші власні незакомічені зміни поруч. Ще один нюанс: merge --abort працює, лише поки злиття не закомічене; після commit відкочувати доведеться інакше. Типовий здоровий сценарій — побачити десяток конфліктів у файлах, яких ви не торкались, зробити merge --abort, підтягнути свіжий main і зливати вже свідомо.
Що робити з конфліктом у package-lock.json?
Не мерджити руками, а перегенерувати. Lock-файли (package-lock.json, yarn.lock, pnpm-lock.yaml) — автогенеровані файли на тисячі рядків, що фіксують точні версії всього дерева залежностей, тож коли дві гілки додали різні пакети, їхні lock-файли майже гарантовано конфліктують на десятках рядків. Ручне злиття тут і безнадійне (граф залежностей у голові не втримати), і небезпечне: правка легко зробить lock неконсистентним із package.json, і збірка почне поводитись непередбачувано. Правильний підхід — спершу осмислено розвʼязати конфлікт у самому package.json, де рядків мало й вони зрозумілі, а потім дати менеджеру пакетів заново вивести коректний lock однією командою install. Джерело істини — package.json, а lock лише його похідна. Коротка точна відповідь «перегенерувати через install» одразу вирізняє того, хто справді працював у команді.
Чим відрізняються конфлікти при merge і при rebase?
При git merge конфлікт виникає один раз: Git намагається зʼєднати кінцеві стани двох гілок, показує всі розбіжності разом, ви розвʼязуєте їх і робите один merge-коміт. При git rebase Git переносить ваші коміти по одному на нову базу, ніби ви написали їх заново поверх свіжого main, тому конфлікт може спливати на кожному вашому коміті окремо: розвʼязали перший — git rebase --continue, наткнулися на другий — знову правка й --continue. Це дратує, бо той самий рядок іноді конфліктує кілька разів (кожен ваш коміт його чіпав), зате історія виходить лінійною, без merge-коміту. Аварійний вихід відрізняється відповідно: git merge --abort проти git rebase --abort. Вибір між ними — це вибір між правдивою історією зі слідами злиттів і чистою лінійною історією, а конфлікти лише один із факторів.
Чому під час rebase значення --ours і --theirs перевертаються?
Бо rebase накладає ваші коміти на нову базу й «своєю» стороною вважає саме базу, а не вашу гілку. При звичайному merge --ours — це ваша поточна гілка, а --theirs — та, яку вливаєте. Під час rebase логіка обертається: ours стає базою (наприклад, свіжий main), а theirs — вашим комітом, який Git у цей момент накладає. Це класична пастка: людина за звичкою робить git checkout --ours, розраховуючи лишити свою правку, а насправді бере чужу. Практичний висновок простий — не покладайтеся на --ours/--theirs наосліп під час rebase, а дивіться на фактичний зміст рядків. Команди git checkout --ours <файл> і git checkout --theirs <файл> корисні, коли ви точно вирішили взяти один бік цілком, але їхнє значення треба щоразу звіряти з контекстом операції.
Тест упав на CI одразу після «розвʼязаного» злиття зі скаргою на синтаксис. Що найімовірніше?
Найімовірніше, в одному з файлів лишився забутий маркер конфлікту — найчастіше самотній рядок =======, який ви не помітили. Такий рядок — синтаксична помилка в майже будь-якій мові, тож білд падає ще до запуску тестів, хоча локально «все виглядало розвʼязаним». Причина зазвичай у тому, що редактор показав частину конфліктів, а один блок лишився поза увагою, або файлів було багато й один пропустили. Тому залізне правило — перед комітом прогнати по всьому дереву пошук <<<<<<<, ======= і >>>>>>>. Кнопки редактора «Accept Current / Incoming / Both» зручні для правки тексту, але відповідальність за те, що жоден маркер не лишився, усе одно на вас. Це дешева перевірка, яка економить червоний пайплайн і зіпсований час колег.
Що таке git rerere і коли він корисний?
git rerere (reuse recorded resolution) — механізм, що запамʼятовує, як саме ви розвʼязали конкретний конфлікт, і застосовує це рішення автоматично, коли той самий конфлікт трапляється знову. Найбільше він рятує під час довгих rebase, де один і той самий рядок конфліктує повторно на кожному з ваших комітів: замість розвʼязувати його вручну знову й знову, ви робите це раз, а далі Git підставляє готове. Це інструмент рівня middle — новачку його знати не обовʼязково, але згадати про існування на співбесіді корисно. Він не змінює саму природу конфлікту, а лише прибирає механічне повторення однакової ручної роботи. Увімкнути його треба явно, після чого Git починає тихо накопичувати ваші розвʼязання.
Як у команді зменшити кількість merge-конфліктів?
Найдешевший конфлікт — той, якого не сталося, і працює тут дисципліна, а не магія. Перше — маленькі короткоживучі PR: що довше гілка живе окремо від main, то далі розходяться історії й то болючіший конфлікт наприкінці; гілка на день зіллється майже завжди, гілка на три тижні з рефакторингом півпроєкту — гарантований бій. Друге — часта синхронізація з базовою гілкою: регулярно підтягуйте main, щоб ловити чужі зміни малими порціями, поки їх легко примирити. Розвʼязати три рядки сьогодні простіше, ніж триста післязавтра. Третє — домовленості про власність коду й автоформатер, щоб не конфліктувати на пробілах і лапках; особливо це стосується спільних config, хелперів і централізованих локаторів у тестах — це гарячі точки, які варто чіпати обережно й узгоджено.
Колега радить при кожному конфлікті видаляти гілку й переробляти з нуля. Чому це погана порада?
Бо вона лікує неіснуючу хворобу: штатний конфлікт розвʼязується за хвилину через правку, git add і git commit, а перестворення гілки викидає вже зроблену роботу й час. Паніка й «видалю все та почну заново» — ознака нерозуміння механізму, а не складності задачі. Конфлікт не псує ваші коміти: вони на місці, Git лише просить розсудити одне спірне місце. Якщо масштаб конфлікту справді лякає, правильна відповідь — не знищувати гілку, а зробити merge --abort, підтягнути свіжий main, оцінити обсяг розбіжностей і зливати свідомо. Знищення гілки виправдане хіба що в рідкісних випадках безнадійно заплутаної історії, і навіть тоді це крайній захід, а не рефлекс на кожен CONFLICT.
Три кейси з робочого життя AQA, де merge-конфлікт вирішує, згаяли ви хвилину чи півдня: розбір і розвʼязання конфлікту в спільному тестовому конфізі, правильна робота з конфліктом у package-lock.json і таблиця рішень «merge чи abort» плюс перевернуті ours/theirs при rebase. Скрізь — що дивитися й чому.
Кейс 1. Конфлікт у спільному тестовому конфізі: правка → add → commit
Ви оновлюєте свою гілку з автотестами від main і бачите зупинене злиття. Перше — не панікувати, а подивитися, де саме розійшлися рядки:
$ git merge main
Auto-merging tests/support/config.ts
CONFLICT (content): Merge conflict in tests/support/config.ts
Automatic merge failed; fix conflicts and then commit the result.
$ git status
On branch feature/qa-env
You have unmerged paths.
(fix conflicts and run "git commit")
Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: tests/support/config.ts
Відкриваємо файл — 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;
>>>>>>> main
Що дивитися й чому:
<<<<<<< HEAD— ваша гілка,>>>>>>> main— те, що вливаєте. Між ними, за роздільником=======, два варіанти того самого місця.HEADтут — вашаfeature/qa-env, бо саме в неї ви зливаєте.- Це не вибір «ліве або праве». Колеги в
mainне просто змінилиBASE_URL, а ще й додали константуRETRIES, якої у вас не було. Механічний «Accept Current» мовчки викине її, і ретраї зникнуть із конфігу — класична тиха втрата чужої потрібної зміни. - Осмислене рішення — поєднати. Береться новий
BASE_URLізmain, але зберігається йRETRIES. Фінальний файл без жодного маркера:
export const LOGIN_TIMEOUT = 5000;
export const BASE_URL = 'https://qa.example.com';
export const RETRIES = 2;
Далі — протокол із трьох кроків. Перед комітом окремо переконуємось, що в дереві не лишилося жодного маркера:
$ grep -rnE '^(<<<<<<<|=======|>>>>>>>)' tests/
# порожній вивід — маркерів немає, можна комітити
$ git add tests/support/config.ts # позначає конфлікт розвʼязаним
$ git status
All conflicts fixed but you are still merging.
$ git commit # створює merge-коміт із готовим повідомленням
git add тут має особливий сенс: він не просто кладе файл у staging, а знімає його зі списку «Unmerged paths». Доки хоч один файл лишається невзгодженим, git commit не завершить злиття. Останній коміт — це merge-коміт із двома батьками, що фіксує сам факт зʼєднання гілок.
Кейс 2. Конфлікт у package-lock.json: не мерджити, а перегенерувати
Ваша гілка додала @faker-js/faker, а в main тим часом оновили @playwright/test. Обидві зміни зачепили дерево залежностей — і lock-файл конфліктує на десятках рядків одразу:
$ git merge main
CONFLICT (content): Merge conflict in package.json
CONFLICT (content): Merge conflict in package-lock.json
Automatic merge failed; fix conflicts and then commit the result.
Спокуса відкрити package-lock.json і звести маркери руками — головна помилка. Файл на тисячі рядків фіксує точні версії всього графа залежностей; втримати його в голові неможливо, а ручна правка легко зробить lock неконсистентним із package.json, після чого збірка почне поводитись непередбачувано. Правильний порядок дій:
# 1. Спершу осмислено розвʼязуємо конфлікт у package.json — там рядків мало
$ git status --short
UU package.json
UU package-lock.json
# (у package.json лишаємо ОБИДВІ нові залежності — і faker, і оновлений playwright)
# 2. Lock не редагуємо — беремо будь-яку сторону, все одно перегенеруємо
$ git checkout --theirs package-lock.json # або --ours, байдуже
# 3. Даємо менеджеру заново вивести коректний lock із фактичного package.json
$ npm install
# 4. Позначаємо обидва файли розвʼязаними
$ git add package.json package-lock.json
$ git commit
Що дивитися й чому:
- Джерело істини —
package.json, а lock лише його похідна. Ви примиряєте зрозумілий список бажаних залежностей, а точні версії дерева менеджер відновлює сам однією командою. git checkout --theirsтут безпечний саме тому, що даліnpm install. Ми не «обираємо чиюсь версію lock» — ми викидаємо конфліктний lock і генеруємо новий. Деякі версії npm/yarn узагалі зводять конфліктний lock автоматично прямо приinstall.- Фінальна перевірка — чисте встановлення. Після коміту варто переконатися, що
npm ciна CI не впаде: він ставить рівно за lock і швидко викриє неконсистентність. І, звісно, у lock не має лишитися жодного маркера.
Кейс 3. merge чи abort, і чому rebase перевертає ours/theirs
Не кожен конфлікт треба розвʼязувати негайно. Іноді масштаб сам підказує, що розумніше відступити й підготуватися. Таблиця рішень для типових ситуацій:
| Ситуація | Що робити | Чому |
|---|---|---|
| Кілька конфліктів у файлах, які ви правили усвідомлено | Розвʼязати: правка → add → commit | Ви розумієте зміст обох сторін, це робота на хвилину |
| Десяток конфліктів у файлах, яких ви не торкались | git merge --abort, підтягнути свіжий main, зливати заново | Гілка сильно відстала; сліпе розвʼязання наосліп калічить чужі зміни |
| Треба скасувати незавершене злиття | git merge --abort, НЕ git reset --hard | --abort знає контекст і не знесе незакомічену роботу поруч |
| Злиття вже закомічене, але виявилось хибним | git revert merge-коміту | --abort після коміту вже не спрацює |
| Той самий рядок конфліктує знов і знов при rebase | Розвʼязати й git rebase --continue після кожного коміту | rebase штатно накладає коміти по одному; це не поломка |
Остання ситуація ховає класичну пастку. При rebase значення --ours і --theirs перевертаються порівняно з merge, бо Git накладає ваші коміти на нову базу й «своєю» вважає базу:
# merge: HEAD — ваша гілка
$ git checkout --ours file # лишити СВОЮ версію
# rebase: ours — це база (свіжий main!), theirs — ваш коміт, який накладається
$ git checkout --theirs file # ось так лишити СВОЮ версію під час rebase
Тому під час rebase не покладайтеся на --ours/--theirs за звичкою — дивіться на фактичний зміст рядків. А сам вибір merge проти rebase у прикладній автоматизації часто вирішується перевіркою на CI: важлива поведінка тесту після злиття, а не краса історії. Простий Playwright-тест-димок після примиреного конфігу дає впевненість, що жодна константа не загубилась при розвʼязанні:
import { test, expect } from '@playwright/test';
import { BASE_URL, RETRIES } from '../support/config';
test('конфіг після злиття цілісний', () => {
// обидві зміни доїхали: і новий URL із main, і власні ретраї
expect(BASE_URL).toBe('https://qa.example.com');
expect(RETRIES).toBe(2);
});Природа конфлікту
- Розумію, що конфлікт — не поломка Git, а чесна відмова вгадати, чия з двох різних правок того самого місця «правильніша»; Git зіставляє рядки тексту, а не зміст коду, тож логічно сумісні правки сам не поєднує.
- Можу пояснити тристороннє злиття (three-way merge): Git бере вашу гілку, чужу й базу злиття (спільного предка) — і саме база дає критерій: правку однієї сторони він застосовує сам, а розходження обох на тому самому рядку зупиняє.
- Можу пояснити, чому правки в різні ділянки одного файлу зливаються без конфлікту, а надто близькі рядки конфліктують навіть без прямого перетину (Git працює блоками, hunk).
Маркери й розвʼязання
- Читаю маркери:
<<<<<<< HEAD— моя версія,=======— роздільник,>>>>>>>— чужа гілка (або хеш коміту при rebase). - Розумію, що розвʼязати — прибрати всі три маркери й лишити правильний фінальний текст; часто відповідь поєднує обидва боки (взяти чужий рядок, але зберегти свою нову константу), а не бере один цілком.
- Памʼятаю протокол: правка файлів →
git add(позначає конфлікт розвʼязаним) →git commit(створює merge-коміт). - Можу пояснити особливий сенс
git addу конфлікті: він знімає файл зі списку «Unmerged paths», а не лише кладе у staging. - Знаходжу конфліктні файли через
git status(«Unmerged paths») іgit diff(самі блоки), а перед комітом завжди прогоняю пошук<<<<<<<,=======,>>>>>>>: забутий маркер — синтаксична помилка й червоний білд на CI. - Розумію, що кнопки редактора «Accept Current / Incoming / Both» лише правлять текст, а відповідальність за коректний результат лишається на мені.
Аварійний вихід і lock-файли
- Знаю різницю
git merge --abortvsgit reset --hard: перший акуратно скасовує саме злиття, другий грубо скидає стан і може знести незакомічену роботу. - Памʼятаю, що
merge --abortпрацює лише до коміту злиття; після нього відкочуютьrevertmerge-коміту. - Розумію здоровий сценарій: десяток чужих конфліктів →
merge --abort→ підтягнути свіжийmain→ зливати свідомо. - Знаю, що lock-файли (
package-lock.json,yarn.lock,pnpm-lock.yaml) не мерджать руками, а перегенеровують черезinstall: джерело істини —package.json, а lock лише похідна, тож ручна правка робить його неконсистентним і ламає збірку.
Merge проти rebase
- Знаю різницю:
mergeконфліктує один раз і лишає merge-коміт;rebase— покомітно, зgit rebase --continueпісля кожного, з лінійною історією. - Памʼятаю, що аварійний вихід відрізняється:
git merge --abortпротиgit rebase --abort. - Розумію пастку перевернутих
--ours/--theirsпри rebase:oursстає базою,theirs— вашим комітом, тож не покладаюся на них наосліп. - Знаю про
git rerere(reuse recorded resolution): запамʼятовує розвʼязання й застосовує його повторно, рятує при довгих rebase.
Профілактика
- Розумію, що маленькі короткоживучі PR — це не лише про ревʼю, а й пряма профілактика конфліктів.
- Можу пояснити, чому часта синхронізація з
mainдешевша: три рядки сьогодні простіше, ніж триста післязавтра. - Знаю, що автоформатер, поділ власності над файлами й обережність зі спільними
config/хелперами/локаторами зменшують площу зіткнення в тестах.
Чому Git зупиняється й повідомляє про merge-конфлікт?
Питання
Merge-конфлікт — це помилка Git?