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

    09 · Git і CI/CD

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

    Зміст

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

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

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

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

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

    Rebase (перебазування) діє інакше: він бере твої коміти D і E, «відклеює» їх від B і відтворює заново поверх C. Виходить рівний ланцюжок A → B → C → D' → E' без жодного коміта злиття. Історія лінійна, ніби ти писав свою фічу вже після того, як зʼявився 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 — він захищає віддалену гілку від затирання. Щоб пропхати переписану історію, потрібен force push (примусовий пуш).

    І тут два інструменти, різниця між якими — улюблене питання на співбесідах.

    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 може «освіжити» його й приспати захист. Тому перед примусовим пушем не роби наосліп зайвих fetch і дивися, що саме переписуєш.

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

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

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

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

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

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

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

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

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

    Чиста (курована) історія — це відредагований результат: лінійний ланцюжок атомарних комітів, де кожен — логічно завершена робоча зміна з осмисленим повідомленням. Досягається 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, хуки й теги».

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

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

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

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

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

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

    Підсумок

    • 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 і застереження, що це безпечно, лише поки гілка не спільна.

    Джерела

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