0% прочитано

Git: як перебудувати feature/release history після передчасного merge і довести, що код не змінився

Реальний Git-кейс із ICanUp: у feature-гілку випадково потрапила лише частина змін, після чого її вже злили в release-гілку, а решту файлів закомітили безпосередньо в release. У статті розбираю, як безпечно зберегти поточний стан, перебудувати feature-гілку, виконати чистий merge і використати `git diff --exit-code` як доказ того, що фінальний код після перебудови залишився незмінним.

27 вересня 2026 р. 8 хв читанняGit

Іноді проблема в Git полягає не в неправильному коді, а в неправильній історії змін. Сам код уже може бути повністю коректним, але історія репозиторію показує зовсім не той шлях, яким зміна мала потрапити до release-гілки.

У ICanUp у мене стався саме такий випадок. Feature-гілка вже існувала, але до її коміту випадково потрапив лише один файл із десяти. Після цього неповну feature-гілку вже злили в release.

Решта дев’ять змінених файлів залишилися в робочій директорії. Після переходу на release-гілку ці незакомічені зміни залишилися разом із робочим деревом і вже там випадково потрапили в окремий прямий коміт.

У результаті код застосунку був правильним і повним, але історія Git - ні. Feature-гілка не містила всієї реалізації, а в release-гілці з’явилися зміни, які взагалі не пройшли через feature-гілку.

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

Кейс із уже pushed commit я розбираю окремо в окремій статті про commit, який уже потрапив у неправильну branch. Тут проблема була іншою: неповний коміт у feature-гілці, передчасний merge і незакомічені зміни, які згодом потрапили в окремий прямий коміт у release-гілці.

Як виглядала неправильна history

Перед виправленням було важливо відділити два факти.

Перший: фінальний code tree на release вже містив усі потрібні зміни.

Другий: ancestry була неправильною. Частина implementation жила у feature, інша частина з’явилася тільки прямим commit у release.

Це важлива різниця, бо наша мета була не "виправити код", а перебудувати ancestry без зміни жодного байта фінального результату.

BASH - Broken history before the rebuild
base  |  +-- partial feature commit  |        |  |        +-- merge into release  |  +---------------------------+                              |                     direct release commit                     with remaining files Final code: correctFeature history: incompleteRelease history: polluted
BASH - Inspect the feature and release history before changing it
# Base before the featureBASE=fa32bf5 # Feature contains only part of the intended changegit switch feature/content-block-drag-fix git log --oneline --decorate -3 # Later it was merged into release,# while the remaining working-tree changes# were committed directly on release.git switch release/news-publishing git log --oneline --decorate -5

Спочатку я створила branch зі старим фінальним tree

Перед reset --hard я не хотіла покладатися лише на reflog як на єдину страховку.

Тому для неправильного з погляду історії, але функціонально коректного стану release-гілки я створила окрему резервну гілку.

Вона виконувала дві ролі: дозволяла швидко повернутися до попереднього стану й водночас слугувала стабільною точкою відліку для фінальної перевірки.

BASH - Створити надійну точку відновлення перед переписуванням історії.
git status -sbgit branch --show-current git branch \    backup/before-history-fix \    7a5c062

Резервна гілка в такій ситуації потрібна не лише для повернення до попереднього стану. Вона також слугує контрольним зразком фінального дерева файлів, з яким можна звірити результат після перебудови історії.

Спочатку потрібно було відновити повну feature

У release-гілці вже був коміт із дев’ятьма файлами, яких бракувало у feature-гілці. Тому я використала його не як частину правильної історії, а як джерело потрібних змін.

Я повернулася до feature-гілки й виконала cherry-pick цього release-коміту. Після цього feature-гілка вже містила повний фінальний стан коду.

Але її історія все ще складалася з двох комітів: початкового неповного коміту та продовження, перенесеного через cherry-pick. Для цієї конкретної feature-гілки я хотіла залишити один цілісний коміт.

BASH - Зібрати усі зміни в один повний коміт feature-гілки.
git switch \    feature/content-block-drag-fix # Bring the missing release-only changes# back onto the feature branch.git cherry-pick 7a5c062 # Rebuild the feature as one clean commit# starting from the real base.git reset --soft fa32bf5 git commit \    -m "Prevent ContentBlock drag text corruption"

Чому тут reset --soft, а не reset --hard

На цьому етапі вміст feature-гілки вже був правильним. Потрібно було змінити лише структуру комітів.

git reset --soft BASE пересуває вказівник гілки назад до базового коміту, але залишає всі накопичені зміни підготовленими до коміту.

Завдяки цьому після soft reset я могла створити один новий коміт, який містив усю реалізацію feature цілком.

reset --hard тут був би неправильним інструментом, адже він видалив би зміни в робочій директорії, які якраз потрібно було зберегти.

Feature-гілку потрібно перевірити до перебудови release-гілки

Після перебудови feature-гілки я не переходила одразу до release.

Спочатку перевірила, що робоча директорія чиста, переглянула нову історію комітів, статистику нового коміту та виконала git diff --check між базовим комітом і поточним станом feature-гілки.

Це ще не доводить повну функціональну еквівалентність застосунку, але допомагає виявити випадкові помилки у пробілах і форматуванні та переконатися, що історія feature-гілки тепер має зрозумілу й цілісну структуру.

BASH - Перевірка перебудованої feature-гілки перед змінами в release-гілці
git status -sb git log \    -3 \    --oneline \    --decorate git show \    --stat \    --oneline \    HEAD git diff \    --check \    fa32bf5..HEAD

Release можна було перебудувати з чистого base

Коли feature-гілка вже містила повну реалізацію, два старі коміти в release-гілці більше не були потрібні.

Я повернула release-гілку до коміту, який передував feature, а потім виконала звичайний merge повної feature-гілки з --no-ff.

Після цього історія знову почала відображати реальний процес розробки: усі зміни належать feature-гілці, а в release вони потрапляють через merge.

BASH - Перебудова release-гілки від початкової бази та чистої feature-гілки
git switch \    release/news-publishing git reset \    --hard \    fa32bf5 git merge \    --no-ff \    feature/content-block-drag-fix \    -m "Merge ContentBlock drag text corruption fix"

Найважливіша перевірка - не log, а tree

На цьому етапі git log уже виглядав правильно. Але правильна історія комітів ще не доводить, що під час перебудови я випадково не втратила або не змінила якийсь файл.

Саме тут резервна гілка стала особливо корисною. Я порівняла старий фінальний стан release-гілки з новим станом після перебудови за допомогою git diff --exit-code.

Якщо команда завершується з кодом 0, це означає, що між двома деревами файлів немає жодних відмінностей.

BASH - Доводимо, що перебудована release-гілка містить точно той самий фінальний код
git diff \    --exit-code \    backup/before-history-fix \    release/news-publishing \    && echo \        "OK: rebuilt release has exactly the same final code"
У цьому випадку `git diff --exit-code` був критично важливою перевіркою: я хотіла змінити історію походження комітів, але зберегти повністю ідентичний фінальний стан файлів.
BASH - Чиста історія після перебудови
base  |  +-- complete feature commit          |          +-- clean merge into release backup/before-history-fix          |          +-- old final tree release/news-publishing          |          +-- rebuilt final tree git diff --exit-code:no difference

Що саме доводить git diff --exit-code

Тут важливо не перебільшувати, що саме доводить ця перевірка.

git diff --exit-code old new підтверджує, що обидва стани репозиторію містять однакові відстежувані файли та їхній вміст.

Але ця команда не доводить, що коміти мають однакові хеші, часові мітки або батьківські коміти. Саме ці частини історії я навмисно змінила.

Вона також не замінює тести застосунку, якщо переписування історії супроводжується змінами в коді. Але коли завдання полягає саме в тому, щоб перебудувати історію без зміни фінального коду, така перевірка дає дуже сильну гарантію незмінності результату.

Повний порядок дій із перевірками безпеки

У реальній роботі я б не запускала команди, які переписують історію Git, без попередньої перевірки чистоти робочої директорії.

Тому повний сценарій починається з set -euo pipefail, перевіряє відсутність незбережених змін, створює резервну гілку, перебудовує feature-гілку та перевіряє її результат. Після цього перебудовується release-гілка, а завершальним кроком стає точне порівняння фінальних дерев файлів.

BASH - Повний процес локальної перебудови історії Git
set -euo pipefail BASE=fa32bf5BROKEN_RELEASE=7a5c062 FEATURE=feature/content-block-drag-fixRELEASE=release/news-publishingBACKUP=backup/before-history-fix echo "===== PRECHECK ====="git status -sb if [ -n "$(git status --porcelain)" ]; then    echo "ERROR: working tree is not clean"    exit 1fi

Коли цей workflow не можна застосовувати напряму

Найважливіша умова в цьому випадку - неправильну історію release-гілки ще не було відправлено у віддалений репозиторій.

Якщо інші розробники вже отримали ці коміти або release-гілка вже використовується спільно, локальний reset --hard із подальшим force-push суттєво змінює ситуацію. Тоді потрібно враховувати чужі локальні копії репозиторію, активні гілки, відкриті merge request і історію розгортань.

У такій ситуації я б не застосовувала цей підхід механічно. Спочатку я б визначила, чи справді варто переписувати спільну історію, чи безпечніше виправити структуру змін новими комітами.

Чого мене навчив цей кейс

  1. Перед комітом потрібно перевіряти не лише назву поточної гілки, а й повний результат git status.

  2. Незакомічені зміни можуть залишитися в робочій директорії після переходу на іншу гілку.

  3. Merge може успішно завершитися навіть тоді, коли feature-гілка містить лише частину запланованих змін.

  4. Перед переписуванням історії варто створити окрему резервну точку, до якої можна безпечно повернутися.

  5. cherry-pick можна використовувати для перенесення потрібних змін, навіть якщо отримана історія не є фінальною.

  6. reset --soft корисний тоді, коли вміст гілки вже правильний, але межі між комітами потрібно перебудувати.

  7. Release-гілку варто перебудовувати лише після того, як feature-гілка містить повну реалізацію.

  8. Фінальний git diff --exit-code перевіряє головну умову перебудови: кінцевий стан файлів залишився незмінним.

Висновок

Цей випадок у Git був незвичним тим, що код застосунку вже був правильним. Виправити потрібно було лише історію змін.

Я зберегла старий стан release-гілки в окремій резервній гілці, повернула зміни, які опинилися лише в release, назад у feature-гілку, зібрала всю реалізацію в один повний коміт і заново виконала чистий merge.

Найважливіше - я не зупинилася на тому, що git log почав виглядати правильно. Старий і новий фінальні стани release-гілки я порівняла безпосередньо.

Якісне переписування історії має не лише виглядати правильним. Воно повинно мати чітку перевірку, яка доводить, що під час перебудови я не змінила нічого з того, що не планувала змінювати.