Іноді проблема в 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 без зміни жодного байта фінального результату.
base | +-- partial feature commit | | | +-- merge into release | +---------------------------+ | direct release commit with remaining files Final code: correctFeature history: incompleteRelease history: polluted# 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-гілки я створила окрему резервну гілку.
Вона виконувала дві ролі: дозволяла швидко повернутися до попереднього стану й водночас слугувала стабільною точкою відліку для фінальної перевірки.
git status -sbgit branch --show-current git branch \ backup/before-history-fix \ 7a5c062Резервна гілка в такій ситуації потрібна не лише для повернення до попереднього стану. Вона також слугує контрольним зразком фінального дерева файлів, з яким можна звірити результат після перебудови історії.
Спочатку потрібно було відновити повну feature
У release-гілці вже був коміт із дев’ятьма файлами, яких бракувало у feature-гілці. Тому я використала його не як частину правильної історії, а як джерело потрібних змін.
Я повернулася до feature-гілки й виконала cherry-pick цього release-коміту. Після цього feature-гілка вже містила повний фінальний стан коду.
Але її історія все ще складалася з двох комітів: початкового неповного коміту та продовження, перенесеного через cherry-pick. Для цієї конкретної 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-гілки тепер має зрозумілу й цілісну структуру.
git status -sb git log \ -3 \ --oneline \ --decorate git show \ --stat \ --oneline \ HEAD git diff \ --check \ fa32bf5..HEADRelease можна було перебудувати з чистого base
Коли feature-гілка вже містила повну реалізацію, два старі коміти в release-гілці більше не були потрібні.
Я повернула release-гілку до коміту, який передував feature, а потім виконала звичайний merge повної feature-гілки з --no-ff.
Після цього історія знову почала відображати реальний процес розробки: усі зміни належать feature-гілці, а в release вони потрапляють через merge.
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, це означає, що між двома деревами файлів немає жодних відмінностей.
git diff \ --exit-code \ backup/before-history-fix \ release/news-publishing \ && echo \ "OK: rebuilt release has exactly the same final code"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-гілка, а завершальним кроком стає точне порівняння фінальних дерев файлів.
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 і історію розгортань.
У такій ситуації я б не застосовувала цей підхід механічно. Спочатку я б визначила, чи справді варто переписувати спільну історію, чи безпечніше виправити структуру змін новими комітами.
Чого мене навчив цей кейс
Перед комітом потрібно перевіряти не лише назву поточної гілки, а й повний результат
git status.Незакомічені зміни можуть залишитися в робочій директорії після переходу на іншу гілку.
Merge може успішно завершитися навіть тоді, коли feature-гілка містить лише частину запланованих змін.
Перед переписуванням історії варто створити окрему резервну точку, до якої можна безпечно повернутися.
cherry-pickможна використовувати для перенесення потрібних змін, навіть якщо отримана історія не є фінальною.reset --softкорисний тоді, коли вміст гілки вже правильний, але межі між комітами потрібно перебудувати.Release-гілку варто перебудовувати лише після того, як feature-гілка містить повну реалізацію.
Фінальний
git diff --exit-codeперевіряє головну умову перебудови: кінцевий стан файлів залишився незмінним.
Висновок
Цей випадок у Git був незвичним тим, що код застосунку вже був правильним. Виправити потрібно було лише історію змін.
Я зберегла старий стан release-гілки в окремій резервній гілці, повернула зміни, які опинилися лише в release, назад у feature-гілку, зібрала всю реалізацію в один повний коміт і заново виконала чистий merge.
Найважливіше - я не зупинилася на тому, що git log почав виглядати правильно. Старий і новий фінальні стани release-гілки я порівняла безпосередньо.
Якісне переписування історії має не лише виглядати правильним. Воно повинно мати чітку перевірку, яка доводить, що під час перебудови я не змінила нічого з того, що не планувала змінювати.



