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

    09 · Git і CI/CD

    Rebase, cherry-pick і переписування історії

    Зміст

    Твоя з автотестами висить у ревʼю третій день, а main за цей час пішов далеко вперед. Ти хочеш підтягнути свіжий код, зробити історію охайною перед — і тут постає вибір: merge чи rebase? А колега мимохідь кидає: «тільки не роби force-push, бо позбиваєш мені коміти». Ці слова — не забобон, а наслідок того, як Git влаштовує історію. Хто не розуміє механіки переписування, той або боїться як вогню, або одного разу зносить чужу роботу однією командою.

    Ця глава — про інструменти, що переписують історію: rebase, інтерактивний rebase, cherry-pick. Усі вони не «редагують» старі , а створюють нові з новими хешами, а старі лишають недосяжними (orphaned). Зрозумієш це — і перестанеш плутати «чисту» історію з «правдивою», а --force з --force-with-lease. Ціна плутанини тут не теоретична: одна команда — і чужі коміти зникають зі спільної гілки.

    Merge vs rebase: дві філософії історії

    Уяви: гілка main має коміти A → B → C, а ти від коміта B відгалузив feature і зробив там D → E. Тепер треба обʼєднати свою роботу з main. Є два способи, і вони дають різну форму графа історії.

    Merge (злиття) створює новий (merge commit) з двома батьками — він зшиває дві гілки, зберігаючи їхню реальну топологію. У графі лишається «розвилка й сходження»: видно, що робота велася паралельно й коли її звели докупи. Механіку three-way merge і детально розібрано в главі «Гілки та злиття».

    Rebase (перебазування) діє інакше: він бере твої коміти D і E, «відклеює» їх від B і відтворює заново поверх C. Виходить рівний ланцюжок A → B → C → D' → E' без жодного merge-коміта. Історія лінійна, ніби ти писав свою фічу вже після того, як зʼявився C.

    Rebase: історія лінійна

    A

    B

    C

    D'

    E'

    Merge: топологію збережено

    A

    B

    C

    M

    D

    E

    Rebase: історія лінійна

    A

    B

    C

    D'

    E'

    Merge: топологію збережено

    A

    B

    C

    M

    D

    E

    Ключове: merge нічого не переписує — старі коміти лишаються собою, додається лише M. Rebase же знищує D і E у тому сенсі, що на їхньому місці в історії гілки тепер стоять інші коміти — D' і E'. Вони містять ті самі зміни, але це вже не ті самі обʼєкти. Чому — розберемо далі, бо це джерело половини непорозумінь навколо rebase.

    Rebase зсередини: чому змінюються хеші

    Кожен коміт у Git має ідентифікатор — хеш (за замовчуванням SHA-1). Це не випадковий номер: хеш обчислюється з вмісту коміта. А до вмісту входять не лише знімок файлів (дерево, tree), а й: хеш батьківського коміта, автор і комітер, повідомлення, часові мітки. Зміни будь-якого з цих полів — і хеш стає іншим.

    Тепер очевидно, чому rebase породжує нові коміти. Коли D переїжджає з батька B на батька C, у нього змінюється батьківський хеш. Отже, обовʼязково змінюється і його власний хеш: батько D змінюється з B на C, і D перетворюється на D'. А оскільки батько E — це D, у E теж змінюється батьківський хеш, тож E стає E'. Ефект каскадний: rebase переписує весь ланцюжок від точки розгалуження й далі.

    Старі D і E нікуди не діваються фізично одразу — вони стають недосяжними (unreachable): на них більше не вказує жодна гілка. Git тримає їх, поки збирач сміття (garbage collection) не почистить недосяжні обʼєкти, а дістатися до них можна через reflog — журнал, де Git пише, куди вказував HEAD у кожен момент. Тому невдалий rebase майже завжди оборотний: reflog показує хеш стану «до», і туди можна повернутися через reset. Це , яку детально розібрано в главі «Скасування змін: reset, revert, stash».

    # Підтягнути свою гілку поверх свіжого main
    git switch feature
    git rebase main
    
    # Щось пішло не так — подивитися журнал і повернутися
    git reflog
    git reset --hard HEAD@{2}

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

    Інтерактивний rebase: squash, reword, drop

    Звичайний rebase просто переносить коміти. Інтерактивний rebase (git rebase -i) дає над ними повний контроль: перед відтворенням Git відкриває редактор зі списком комітів і командами, які можна на них навісити.

    git rebase -i HEAD~4   # взяти під редагування останні 4 коміти

    У редакторі зʼявляється так званий todo-список — коміти в порядку від старіших до новіших, кожен зі словом-командою:

    pick a1b2c3d Додати перевірку логіну
    squash e4f5g6h fix друкарки в тесті
    reword i7j8k9l WIP
    drop  m0n1o2p тимчасовий console.log

    Найуживаніші команди:

    КомандаЩо робить
    pickлишити коміт як є (дефолт)
    reword (r)лишити зміни, але переписати повідомлення
    squash (s)вліпити коміт у попередній, обʼєднавши обидва повідомлення
    fixup (f)те саме, що squash, але повідомлення цього коміта викинути
    edit (e)зупинитися на цьому коміті, щоб доредагувати зміни
    drop (d)викинути коміт цілком

    Порядок рядків = порядок комітів, тож їх можна ще й переставляти. squash і fixup — головні робочі конячки перед відкриттям PR: п'ять комітів на кшталт «wip», «fix», «ще fix» згортаються в один осмислений «Додати E2E-перевірку відновлення пароля». reword виправляє соромʼязливе повідомлення, drop прибирає випадковий console.log, що заліз в окремий коміт.

    Тут важлива та сама механіка: інтерактивний rebase переписує всі зачеплені коміти, навіть ті, які ти позначив pick. Досить змінити один коміт у середині — і всі, що йдуть після нього, отримають нові хеші, бо в них зміниться ланцюжок батьків. Це прямий місток до наступного розділу.

    Золоте правило: не переписуй спільну історію

    Ось головне правило, заради якого існує ця глава: не переписуй історію, яка вже вийшла за межі твого локального репозиторію й на яку могли спертися інші. Формулювання з книги Pro Git пряме: не роби rebase комітів, які ти вже запушив у публічну гілку, де на них базується чужа робота.

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

    Так

    Ні, вже запушені
    у спільну гілку

    Ні, це моя
    особиста feature-гілка

    Так

    Хочу переписати ці коміти

    Вони існують лише
    у моєму локальному репо?

    Rebase безпечний

    Хтось інший
    працює з цією гілкою?

    Можна, але буде
    потрібен force-push

    Не переписуй.
    Роби merge або revert

    Так

    Ні, вже запушені
    у спільну гілку

    Ні, це моя
    особиста feature-гілка

    Так

    Хочу переписати ці коміти

    Вони існують лише
    у моєму локальному репо?

    Rebase безпечний

    Хтось інший
    працює з цією гілкою?

    Можна, але буде
    потрібен force-push

    Не переписуй.
    Роби merge або revert

    Практична межа проста. Гілка, яку бачиш лише ти (не пушив, або пушив у свій особистий feature-бранч, з яким більше ніхто не працює), — переписуй скільки завгодно, це нормальна гігієна. Спільна гілка на кшталт main чи develop, або гілка, куди вже комітить колега, — недоторканна: там правки вносять тільки новими комітами. Для скасування вже опублікованого коміта є revert, що додає коміт-антидот, не чіпаючи історію (див. главу «Скасування змін»).

    --force vs --force-with-lease

    Коли ти перебазував свою feature-гілку, яку вже пушив, виникає технічна проблема: локальна гілка й віддалена розійшлися. Твій новий ланцюжок D'→E' не є прямим продовженням старого D→E на сервері. Звичайний git push Git відхилить із помилкою non-fast-forward — він захищає віддалену гілку від затирання. Щоб пропхати переписану історію, потрібен .

    І тут два інструменти, різниця між якими вирішує долю чужих комітів.

    git push --force каже серверу: «постав гілку на мій коміт, що б там зараз не було». Він затирає віддалену гілку беззастережно. Проблема: якщо між твоїм останнім fetch і цим пушем колега встиг запушити свій коміт, --force знесе його безслідно. Класична катастрофа спільної гілки.

    git push --force-with-lease додає запобіжник: він оновить віддалену гілку, тільки якщо вона й досі там, де ти востаннє її бачив (на твоєму remote-tracking посиланні). Якщо хтось запушив після твого останнього fetch, сервер уже не на очікуваному коміті — і push відхиляється. Ти дізнаєшся про чужу роботу замість того, щоб її стерти. «Lease» — це «оренда»: ти ніби взяв гілку в оренду в тому стані й дієш, лише поки цей стан не змінився.

    # Небезпечно: затре все, що на сервері, навіть чужі свіжі коміти
    git push --force
    
    # Безпечніше: відхилить push, якщо хтось пушив після твого fetch
    git push --force-with-lease

    Правило-звичка: якщо взагалі доводиться force-пушити — завжди --force-with-lease. Одна засторога: --force-with-lease порівнює із твоїм remote-tracking посиланням, тож автоматичний або фоновий git fetch може «освіжити» його й приспати захист. Тому перед force push не роби наосліп зайвих fetch і дивися, що саме переписуєш.

    cherry-pick: один коміт без цілої гілки

    cherry-pick вирішує іншу задачу: узяти один конкретний коміт і застосувати його зміни на поточну гілку, не тягнучи за собою всю гілку, звідки він родом.

    git switch release/2.4
    git cherry-pick a1b2c3d   # застосувати саме цей фікс на реліз-гілку

    Механіка та сама, що й у rebase: не «переносить» коміт, а створює новий коміт із новим хешем, який містить той самий діф (набір змін) і те саме повідомлення, але з іншим батьком. Тобто після cherry-pick одна й та сама зміна фізично існує у двох комітах з різними хешами.

    Найтиповіший сценарій — бекпорт (backport) виправлення: критичний баг полагоджено в main, але цей фікс потрібен ще й у старій реліз-гілці, куди весь main мерджити не можна. Береш один коміт із фіксом і cherry-pick-аєш його в реліз. Для AQA це часта операція: гарячий фікс автотесту чи конфігурації треба доставити у гілку, з якої вже збирають реліз.

    -x дописує в повідомлення рядок (cherry picked from commit ...) — сліди, звідки коміт прийшов. Це рятує від плутанини, коли той самий фікс живе у двох гілках. Але памʼятай про зворотний бік: cherry-pick множить коміти з однаковими змінами, і зловживання ним засмічує історію дублікатами, які потім спантеличують при мерджі й читанні git log.

    git cherry-pick

    конфлікту немає

    зміни перетинаються

    розвʼязати, git add, --continue

    --skip

    --abort

    Накладання зміни на гілку

    Конфлікт: ті самі рядки змінено по-різному

    Новий коміт із тим самим діфом

    Коміт пропущено, серія триває далі

    Гілка як до cherry-pick

    git cherry-pick

    конфлікту немає

    зміни перетинаються

    розвʼязати, git add, --continue

    --skip

    --abort

    Накладання зміни на гілку

    Конфлікт: ті самі рядки змінено по-різному

    Новий коміт із тим самим діфом

    Коміт пропущено, серія триває далі

    Гілка як до cherry-pick

    Ключова думка діаграми: конфлікт при cherry-pick — не глухий кут, а розвилка з трьома виходами: розвʼязати його, зробити git add і git cherry-pick --continue; викинути цей коміт через --skip; або відмотати все до початкового стану через --abort.

    «Чиста» vs «правдива» історія

    За всіма цими інструментами стоїть один світоглядний холівар. Що має відображати історія — як воно було насправді чи як його зручно читати?

    Правдива історія фіксує реальний хід подій: усі «wip», «fix typo», merge-коміти, метання туди-сюди. Ніщо не переписано, граф показує фактичну паралельну роботу. Аргумент: історія — це журнал, а журнали не підробляють.

    Чиста (курована) історія — це відредагований результат: лінійний ланцюжок атомарних комітів, де кожен — логічно завершена робоча зміна з осмисленим повідомленням. Досягається rebase-ом і squash-ем перед мерджем. Аргумент: історію читають набагато частіше, ніж пишуть, і охайний git log — це документація.

    Однозначно правильної відповіді немає — це командна домовленість. Squash-merge на злитті PR дає гранично чисту історію ціною втрати проміжних кроків; merge-коміти зберігають контекст ціною шуму. Стратегії злиття PR (merge commit vs squash vs rebase) розібрано в главі «Pull Request і стратегії гілкування».

    Чому це не абстракція для AQA. Чиста лінійна історія, де кожен коміт складається й проходить тести, робить git bisect — бінарний пошук коміта, що зламав тест, — по-справжньому дієвим: пошук приземляється на реальну зміну-винуватця. Історія з поламаними проміжними комітами й густими merge-комітами перетворює bisect на муку. Так само blame читається чисто, коли коміти атомарні. Ці інструменти пошуку винуватця — тема глави «Git для AQA: bisect, blame, хуки й теги».

    Git не є окремою темою силабусу ISTQB CTFL 4.0 — управління версіями там згадується лише в межах управління конфігурацією (configuration management), тож канонічне джерело з цієї теми — офіційна документація Git і книга Pro Git.

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

    • Виглядає як «rebase зламав мою гілку», а насправді коміти цілі — просто змінилися їхні хеші, а старий стан лежить у reflog. Перш ніж панікувати після невдалого rebase, подивися git reflog і зроби reset --hard на потрібний запис.

    • Виглядає як «--force — це просто “запушити напевно”», а насправді --force беззастережно затирає віддалену гілку разом із чужими комітами, які зʼявилися після твого fetch. За замовчуванням бери --force-with-lease.

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

    • Виглядає як «cherry-pick переніс фікс в іншу гілку», а насправді він створив другий коміт із тим самим дифом і новим хешем. Це не той самий коміт; при подальших мерджах дублікат може дати конфлікт або порожнє злиття.

    • Виглядає як «squash усе згорнув, історія чиста», а насправді якщо це вже опублікована спільна гілка, ти щойно переписав чужу базу. Squash перед PR — так; squash уже змерджених у main комітів — ні.

    Підсумок

    • Merge зберігає топологію й додає merge-коміт; rebase робить історію лінійною, відтворюючи коміти поверх нової бази.
    • Rebase, інтерактивний rebase і cherry-pick не редагують старі коміти — вони створюють нові з новими хешами, бо хеш залежить від батька, а він змінюється. Старий стан рятує reflog.
    • Золоте правило: переписувати можна лише історію, що не вийшла за межі твого локального репозиторію (чи твоєї особистої гілки). Спільні гілки правлять тільки новими комітами (revert).
    • Якщо потрібен force push — завжди --force-with-lease, а не --force: перший відхилить пуш, якщо хтось встиг запушити, другий мовчки зітре чуже.
    • «Чиста» vs «правдива» історія — командна домовленість, а не абсолют; але чиста лінійна історія робить bisect і blame реально корисними для пошуку багів.

    Можливі питання

    • «У чому різниця між merge і rebase?» Інтервʼюер хоче почути про форму графа (merge-коміт і збережена топологія vs лінійна історія) і про те, що rebase переписує коміти з новими хешами. Слабка відповідь зупиняється на «rebase робить красивіше»; сильна пояснює механізм і наслідки.
    • «Що таке золоте правило rebase / чому не можна rebase-ити спільну гілку?» Перевіряють, чи розумієш ти, що переписування опублікованої історії породжує розбіжність для інших. Очікують згадку про revert як безпечну альтернативу для публічної історії.
    • «Чим --force-with-lease кращий за --force Тут дивляться, чи розумієш ти конкретний механізм захисту: --force-with-lease відхиляє пуш, якщо віддалена гілка змістилася відносно твого останнього fetch, тобто не дає стерти чужу свіжу роботу.
    • «Навіщо cherry-pick і чим він відрізняється від merge?» Хочуть почути про бекпорт одного коміта в реліз-гілку й про те, що cherry-pick створює новий коміт-копію (дублікат діфа з іншим хешем), а не переносить оригінал.
    • «Як виправити останні кілька комітів перед PR?» Очікують git rebase -i зі squash/reword/drop і застереження, що це безпечно, лише поки гілка не спільна.

    Джерела

    Merge vs rebase: дві філософії історії

    • Pro Git — 3.6 Rebasing — способів інтеграції рівно два; rebase перепрокручує зміни в порядку появи, merge бере кінцеві точки й зливає їх, а кінцевий знімок виходить той самий — різна лише історія.
    • Pro Git — 3.2 Basic Branching and Merging — бік merge: three-way merge створює merge-коміт «з більш ніж одним батьком», який і зшиває дві лінії.

    Rebase зсередини: чому змінюються хеші

    • Pro Git — 3.6 Rebasing — rebase «покидає наявні коміти й створює нові — схожі, але інші»; механіка описана покроково, від спільного предка до накладання діфів.
    • Pro Git — 1.3 What is Git? — чому хеш неминуче інший: усе в Git хешується перед збереженням і адресується цією сумою вмісту.
    • Git Reference — git-reflog — журнал рухів рефів у локальному репозиторії й синтаксис HEAD@{N}, яким вертаються до стану «до rebase».

    Інтерактивний rebase: squash, reword, drop

    • Git Reference — git-rebase-i показує список комітів, які збираються ребейзитися, і дає перевпорядкувати, видалити чи розділити їх до початку.
    • Pro Git — 7.6 Rewriting History — команди todo-списку дослівно (reword, edit, squash, fixup, drop), виконання згори вниз і попередження If you remove a line here THAT COMMIT WILL BE LOST.

    Золоте правило: не переписуй спільну історію

    • Pro Git — 3.6 Rebasing — правило дослівно: Do not rebase commits that exist outside your repository and that people may have based work on; три випадки (локальне / запушене без надбудов / публічне) розкладені джерелом.
    • Pro Git — 7.6 Rewriting History — той самий припис для -i: не включати коміти, вже запушені на центральний сервер, бо це дає іншим альтернативну версію тієї самої зміни.

    --force vs --force-with-lease

    • Git Reference — git-pushpush за замовчуванням відмовляється оновлювати реф, який не є предком локального; --force вимикає ці захисти, і «віддалений репозиторій може втратити коміти»; --force-with-lease перезаписує лише за збігу з очікуваним значенням і дуже погано взаємодіє з фоновим git fetch.
    • Pro Git — 3.6 Rebasing — що бачить потерпіла сторона: дублікати з тим самим автором, датою й повідомленням, і patch-id як спосіб їх розрізнити.

    cherry-pick: один коміт без цілої гілки

    • Git Reference — git-cherry-pick — команда «накладає зміну, внесену наявним комітом», і записує на кожну новий коміт; сценарій бекпорту в підтримувану релізну гілку названий дослівно, а -x дописує рядок (cherry picked from commit …) — але лише для пікiв без конфліктів.

    «Чиста» vs «правдива» історія

    • Pro Git — 3.6 Rebasing — джерело подає це як дві протилежні позиції (історія як запис подій проти історії як розповіді) і переможця не оголошує; практичний компроміс — rebase лише локального.
    • GitHub Docs — About merge methods — ціна squash названа поіменно: втрачені авторство й час змін, повторні конфлікти в наступному PR і втрачені SHA оригінальних комітів.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.