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

    09 · Git і CI/CD

    Гілки та злиття

    Зміст

    Гілка (branch) звучить страшно — наче «окрема копія проєкту» чи «паралельний всесвіт коду». Насправді все прозаїчніше: гілка — це рухомий вказівник на один коміт, файл розміром у кілька десятків байтів. Зрозумієш це одне речення — і решта теми (HEAD, злиття, detached-стан) складеться сама, бо все крутиться навколо того, куди який вказівник дивиться.

    Для AQA гілки — щоденний інструмент, а не абстракція. Свій код автотестів ти пишеш у гілці, віддаєш його в pull request, а в CI пайплайн запускається саме на конкретній гілці й конкретному коміті. Питання «чому тест зелений локально, а в пайплайні червоний» дуже часто починається з «а на якій гілці й на якому коміті він насправді запустився». І на співбесіді в junior-AQA різницю між fast-forward і звичайним злиттям питають часто: це швидкий маркер, що людина розуміє модель Git, а не завчила три команди.

    Гілка — це вказівник на коміт

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

    Гілка на цьому тлі — річ навмисно примітивна. Це іменований вказівник на один коміт (той, що вважається «кінцем» гілки, її tip). Фізично він лежить у файлі .git/refs/heads/<назва> (або спакований у packed-refs) і містить лише хеш одного коміту. Створити гілку — це записати ці кілька десятків байтів, тому в Git гілкування миттєве й дешеве, на відміну від старих систем, що копіювали файли.

    Найважливіший наслідок: коли ти робиш коміт, вказівник поточної гілки автоматично переїжджає вперед на новий коміт. Історію тримають самі коміти через посилання на батьків; гілка лише позначає, де ця лінія зараз закінчується.

    C1

    C2

    C3

    C4

    main

    feature

    HEAD

    C1

    C2

    C3

    C4

    main

    feature

    HEAD

    Суцільні стрілки тут — «коміт вказує на свого батька», пунктирні — «вказівник вказує на коміт». main і feature розійшлися на коміті C2: main пішла в C3, feature — в C4. Жодного дублювання файлів — лише два вказівники на два коміти спільного дерева.

    HEAD і detached HEAD

    Є ще один особливий вказівник — HEAD. Він відповідає на питання «де я зараз». Здебільшого HEAD вказує не прямо на коміт, а на гілку: у файлі .git/HEAD лежить рядок ref: refs/heads/main. Тобто ланцюжок такий: HEAD показує на гілку, гілка показує на коміт. Саме тому, коли ти комітиш, Git знає, який вказівник рухати вперед — той, на який зараз дивиться HEAD.

    Це нормальний, «прикріплений» стан: ти стоїш на гілці, і нові коміти лягають на неї.

    Detached HEAD («відʼєднаний HEAD») — це коли HEAD вказує не на гілку, а прямо на конкретний коміт. Ти потрапляєш туди, коли перемикаєшся на коміт за його хешем, на тег або на віддалений стан безпосередньо:

    git checkout 3a7f1c9        # переходимо прямо на коміт
    git checkout v1.4.0         # тег — теж коміт, а не гілка

    У detached-стані все працює: можна дивитися код, білдити, ганяти тести й навіть комітити. Пастка в тому, що нові коміти не належать жодній гілці. Щойно ти перемкнешся деінде, на них не залишиться жодного вказівника — вони стають недосяжними й з часом їх прибирає збирач сміття (garbage collector). Врятувати їх можна лише через reflog (див. скасування змін). Якщо роботу в detached-стані треба зберегти — прикріпи до неї гілку:

    git switch -c fix/hotfix-from-tag   # створюємо гілку прямо тут, коміти врятовано

    Для AQA detached HEAD — не екзотика, а буденність CI. Типовий чекаут у пайплайні (наприклад, actions/checkout у GitHub Actions на подію push) лишає репозиторій саме у detached-стані на конкретному SHA — тому в логах CI ти бачиш не назву гілки, а хеш. Це нормально: агент не «сидить на гілці», він відтворює точний коміт, який тригернув запуск.

    switch проти checkout

    Історично одна команда git checkout робила надто багато: перемикала гілки, створювала їх, відновлювала окремі файли й відʼєднувала HEAD. Це збивало новачків — той самий git checkout міг як перемкнути гілку, так і мовчки затерти твої зміни у файлі.

    Тому у Git 2.23 (2019) обовʼязки розвели на дві команди з говорючими назвами:

    • git switch — тільки перемикання й створення гілок;
    • git restore — тільки відновлення файлів (детальніше — у главі про скасування змін).

    git checkout нікуди не зник і досі працює, але для ясності новий код варто читати й писати через switch. Основні операції:

    git switch main                 # перейти на наявну гілку main
    git switch -c feature/search    # створити гілку feature/search і одразу перейти на неї
    git switch -                    # повернутися на попередню гілку
    git switch --detach 3a7f1c9     # свідомо піти в detached-стан на коміт

    Ключова перевага switch — він безпечніший: не дасть випадково відʼєднати HEAD без явного --detach і чіткіше повідомляє про неоднозначні ситуації. Стара пара тих самих дій виглядала як git checkout main і git checkout -b feature/search.

    Злиття: fast-forward проти three-way merge

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

    Fast-forward («перемотка»). Уяви, що поки ти працював у feature, гілка main не рухалася. Тоді коміт main — прямий предок кінця feature, історія лінійна, і зливати нічого: Git просто пересуває вказівник main уперед на кінець feature. Жодного нового коміту не створюється.

    git switch main
    git merge feature     # main не рухалась → fast-forward: вказівник просто переїхав уперед

    Three-way merge (трибічне злиття). А тепер уяви, що поки ти працював, колеги влили свої коміти в main. Тепер обидві гілки мають власні нові коміти після точки розходження — історія розійшлася, і простою перемоткою вказівника не обійтися. Git бере три коміти: кінець main, кінець feature і їхнього спільного предка (merge base), зводить зміни докупи й створює новий merge-коміт із двома батьками.

    M (merge-коміт)

    A1 (спільний предок)

    A2

    A3

    B1

    B2

    main

    feature

    M (merge-коміт)

    A1 (спільний предок)

    A2

    A3

    B1

    B2

    main

    feature

    Merge-коміт M має двох батьків (A3 з боку main і B2 з боку feature) — саме за наявністю двох батьків його впізнають у графі. Якщо обидві гілки правили ті самі рядки, Git не вгадає, чию версію взяти, і зупиниться на merge-конфлікті — це окрема велика тема наступної глави.

    Різниця між шляхами — не косметична, вона визначає форму історії:

    АспектFast-forwardThree-way merge
    Коли можливийЦільова гілка не рухаласьОбидві гілки розійшлися
    Новий комітНі, лише зсув вказівникаТак, merge-коміт із двома батьками
    Форма історіїЛінійнаЗ «розгалуженням» і злиттям
    Видно, що був окремий featureНіТак (топологія збережена)
    Можливий конфліктНіТак

    Керувати цим вибором можна прапорцями:

    • git merge --no-ff feature — примусово створити merge-коміт, навіть коли можлива перемотка. Так роблять, щоб у графі лишався слід, що коміти належали окремій фічі.
    • git merge --ff-only feature — злити лише якщо можлива перемотка, інакше впасти з помилкою. Зручно в скриптах і CI, коли лінійну історію тримають навмисно.

    Який стиль обрати на рівні команди (merge-коміт, squash чи rebase) — це вже питання стратегії гілкування й pull request. Альтернатива злиттю, яка переписує історію лінійно замість merge-коміту, — це rebase.

    Іменування гілок

    Назва гілки — це не формальність, а комунікація: за нею колеги (і пайплайн) розуміють, що всередині. Усталена практика — префікс за типом роботи плюс ідентифікатор задачі й короткий опис:

    feature/JIRA-123-add-search-filter
    bugfix/PROJ-88-login-500
    hotfix/payment-timeout
    release/2.4.0
    

    Кілька практичних правил:

    • Слеш (/) створює умовне «групування» — багато інструментів показують feature/* як теку. Через це не можна одночасно мати гілку feature і гілку feature/login: ім`я не буває водночас файлом і текою.
    • Без пробілів, у нижньому регістрі, слова через дефіс. Ідентифікатор задачі в назві звʼязує гілку з таск-трекером і майбутнім pull request.
    • Головна гілка проєкту історично звалася master; нині за замовчуванням її частіше називають main (GitHub перейшов на main для нових репозиторіїв у 2020 році). Обидві назви — просто звичайні гілки без магії, «головна» лише за домовленістю.

    Для AQA іменування має ще й технічний бік: правила CI часто матчаться саме за назвою гілки. Пайплайн може запускати повний прогін тільки на release/*, піднімати окреме превю-середовище на кожну feature/*` або ганяти лише смоук на решті. Неохайна назва — і твоя гілка не потрапить під потрібне правило, а тести мовчки не запустяться там, де мали б.

    Видалення гілок: локально й на remote

    Гілка — дешевий вказівник, тож після вливання її прибирають, щоб не засмічувати список. Важливо памʼятати: локальна гілка й однойменна гілка на сервері (remote) — дві незалежні речі, видалення однієї не чіпає іншу.

    Локально:

    git branch -d feature/search    # видалити, ТІЛЬКИ якщо гілку вже влито (безпечно)
    git branch -D feature/search    # примусово, навіть якщо не влито (можна втратити коміти)

    Мала -d — запобіжник: Git не дасть видалити гілку з невлитими комітами. Велика -D цей запобіжник знімає; якщо коміти нікуди більше не вказують, вони стануть недосяжними (рятує знову ж таки reflog). Поточну гілку — ту, на якій ти стоїш, — видалити не можна: спершу перейди на іншу.

    На remote:

    git push origin --delete feature/search    # видалити гілку на сервері

    Окрема пастка: після того як гілку видалили на сервері, у тебе локально ще довго висять remote-tracking-посилання на кшталт origin/feature/search — Git не прибирає їх сам. Почистити:

    git fetch --prune          # прибрати посилання на гілки, яких уже немає на сервері

    Щоб це відбувалося автоматично при кожному fetch, вмикають git config --global fetch.prune true. Багато команд ще й налаштовують GitHub/GitLab так, щоб гілка видалялася автоматично після мержу pull request — тоді ручне прибирання майже не потрібне.

    Типовий флоу фічі

    Складемо все докупи в послідовність, яку AQA проходить щодня, коли додає чи править автотести (чи будь-хто — фічу):

    1. Оновитися від головної. git switch main і git pull — щоб стартувати з найсвіжішого стану, а не від тижневої давнини.
    2. Створити гілку. git switch -c feature/JIRA-123-add-search — окремий простір для своєї роботи.
    3. Працювати маленькими комітами. Правки, git add, git commit — кілька осмислених комітів краще за один величезний.
    4. Опублікувати гілку. git push -u origin feature/JIRA-123-add-search — прапорець -u привʼязує локальну гілку до віддаленої, далі досить голого git push. Саме цей push зазвичай і тригерить CI.
    5. Відкрити pull request і пройти рев`ю (окрема глава); на зауваження — нові коміти в ту саму гілку.
    6. Влити обраним командою способом, коли пайплайн зелений і рев`ю пройдено.
    7. Прибрати за собою. git switch main, git pull, видалити локальну й віддалену гілку.

    Маленька гілка з коротким життям — не естетика, а профілактика: чим довше вона живе окремо від main, тим більше та встигає піти вперед і тим болючіші майбутні конфлікти при вливанні.

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

    • Виглядає як «гілка — це копія файлів», а насправді — один вказівник на коміт. Тому створення гілки миттєве й нічого не «копіює»; сплутати ці моделі — значить боятися гілкування там, де воно безкоштовне.
    • Виглядає як «мої коміти зникли», а насправді — вони в detached HEAD. Накомітив, стоячи на голому SHA, перемкнувся — і не бачиш роботи. Коміти живі, поки їх тримає reflog; витягуються через git switch -c на потрібний хеш.
    • Виглядає як «Git видалив мою гілку в CI», а насправді — це штатний detached-стан. У логах пайплайна замість назви гілки — хеш коміту; це нормальний чекаут на конкретний SHA, а не поломка.
    • Виглядає як «fast-forward зламав історію», а насправді — він її просто спрямив. Після перемотки в графі не лишається сліду, що був окремий feature. Якщо команді цей слід потрібен — це аргумент за --no-ff, а не баг.
    • Виглядає як «видалив гілку, а вона ще в списку», а насправді — висить застаріле origin/*-посилання. Лікується git fetch --prune, а не повторним видаленням.
    • Виглядає як «тест червоний через баг», а насправді — гілка сильно відстала від main. Автотест ганяється на старому стані, де фічі ще немає або поведінка інша. Спершу підтягни main, потім став діагноз застосунку — це продовження принципу «фейл автотесту ≠ баг продукту».

    Підсумок

    • Гілка — це рухомий іменований вказівник на один коміт; новий коміт лягає туди, куди дивиться HEAD, і вказівник поточної гілки автоматично їде вперед.
    • HEAD зазвичай показує на гілку; коли він показує прямо на коміт (detached HEAD), нові коміти не належать жодній гілці й легко губляться.
    • Fast-forward лише пересуває вказівник по лінійній історії; three-way merge створює merge-коміт із двома батьками, коли гілки розійшлися, і тільки він може дати конфлікт.
    • git switch/git restore — сучасна заміна перевантаженому git checkout; локальна й віддалена гілки незалежні, а -d захищає від видалення невлитого.
    • Осмислена назва гілки — це і комунікація, і матчинг правил CI; коротке життя гілки економить майбутні конфлікти.

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

    • «Що таке гілка в Git?» Слабка відповідь — «окрема версія проєкту». Сильна — «рухомий вказівник на коміт, фактично файл із хешем»; інтервʼюер перевіряє, чи розумієш модель, а не завчив команди.
    • «Чим fast-forward відрізняється від three-way merge?» Очікують: перемотка вказівника при лінійній історії проти merge-коміту з двома батьками при розходженні гілок; бонус — коли Git обирає який і навіщо --no-ff.
    • «Що таке detached HEAD і як туди потрапити?» Дивляться, чи звʼяжеш це з checkout на коміт/тег, з ризиком втратити коміти й із порятунком через нову гілку — і чи впізнаєш цей стан у логах CI.
    • git switch vs git checkout? Перевіряють обізнаність зі сучасним Git (2.23+) і розуміння, чому старий checkout був неоднозначним.
    • «Як видалити гілку локально й на сервері й у чому різниця -d і -D Тут цінують згадку про незалежність local/remote і про застарілі origin/*-посилання та --prune.
    • «Опишіть свій типовий git-флоу для нової задачі». Поведінкове питання: слухають дисципліну (гілка від свіжої main, маленькі коміти, PR, прибирання) — сигнал, що з людиною буде комфортно в команді.

    Джерела