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

    09 · Git і CI/CD

    Скасування змін: reset, revert, stash

    Зміст

    Рано чи пізно кожен, хто працює з Git, робить одне з чотирьох: комітить не те, комітить не в ту гілку, псує повідомлення коміту або хоче тимчасово відкласти незавершене, щоб перемкнутися на термінову задачу. Для AQA це буденність: тестовий код живе в git так само, як продуктовий, і плутанина «закомітив половину рефакторингу page object у master замість фіча-гілки» трапляється частіше, ніж хотілося б. Скасування лякає, бо здається, що одним рухом можна знищити годину роботи — і саме через цей страх люди або паралізуються, або навпаки рубають git reset --hard наосліп.

    Ключ до впевненості — не завчити команди, а зрозуміти, на яку з трьох зон Git (робоча директорія, індекс, репозиторій — детально в главі «Системи контролю версій і модель Git») кожна команда діє. Тоді видно, що майже все оборотне, і лише одна операція справді небезпечна. На співбесідах це тема з високою вагою: питання «чим reset відрізняється від revert» — класика, на якій відсіюють тих, хто вчив команди напам'ять, не розуміючи моделі.

    Три зони — і що означає «скасувати»

    Нагадаємо картину з базового циклу (глава «Базовий цикл Git»). Зміна проходить три стани:

    • Робоча директорія (working directory) — файли, які ви редагуєте зараз.
    • Індекс (index, staging area) — чернетка наступного коміту; те, що ви додали через git add.
    • Репозиторій (repository) — послідовність зафіксованих комітів; на верхівку вашої гілки вказує HEAD.

    «Скасувати зміни» — розмите формулювання, і саме тому новачки хапають не ту команду. Питання завжди конкретне: на якій зоні лежить те, що я хочу відкотити? Не закомічена правка в файлі, помилково застейджений файл, останній коміт, коміт тижневої давнини в спільній гілці — це чотири різні ситуації з чотирма різними інструментами. Далі — по кожній.

    restore: відкотити незакомічене

    git restore (з'явився в Git 2.23) — сучасна команда для скасування змін, які ще не потрапили в коміт. Вона розводить два різні наміри, які раніше плутали в перевантаженому git checkout.

    # Викинути незастейджені правки у файлі — повернути його до стану в індексі
    git restore tests/login.spec.ts
    
    # Прибрати файл зі staging (розстейджити), правки в робочій директорії лишаються
    git restore --staged tests/login.spec.ts

    Різниця принципова. git restore <файл> знищує ваші незбережені правки в робочій директорії — повертає файл до останньої застейдженої (або закомміченої) версії. git restore --staged <файл> нічого не знищує: він лише виймає файл з чернетки коміту, а самі зміни лишаються у вас під руками.

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

    reset: рухаємо вказівник гілки

    git reset робить одну концептуальну річ: пересуває вказівник поточної гілки на інший коміт. А що станеться з індексом і робочою директорією — визначає режим. Саме тут живуть три прапорці, які питають на кожній другій співбесіді.

    Уявіть, що ви на коміті C (історія A → B → C) і робите git reset HEAD~1, тобто відкочуєте гілку на B. Коміт C більше не в історії гілки. Питання лише в тому, куди подінуться зміни, що були в C:

    Три зони

    рухає

    рухає

    скидає до нового HEAD

    рухає

    скидає

    скидає

    HEAD / вказівник гілки

    Індекс (staging)

    Робоча директорія

    reset --soft

    reset --mixed
    (за замовчуванням)

    reset --hard

    Три зони

    рухає

    рухає

    скидає до нового HEAD

    рухає

    скидає

    скидає

    HEAD / вказівник гілки

    Індекс (staging)

    Робоча директорія

    reset --soft

    reset --mixed
    (за замовчуванням)

    reset --hard

    | Режим | HEAD (гілка) | Індекс | Робоча директорія | Де опиняться зміни з відкоченого коміту | | --- | --- | --- | --- | | --soft | рухає | не чіпає | не чіпає | застейджені, готові до нового коміту | | --mixed (дефолт) | рухає | скидає | не чіпає | у робочій директорії, незастейджені | | --hard | рухає | скидає | скидає | знищуються |

    Звідси випливає практика:

    • --soft — «переграти останній коміт». Ви відкотили C, але всі його зміни лишились застейдженими. Класичний кейс: закомітили зарано або хочете зібрати кілька дрібних комітів в один. git reset --soft HEAD~1 — і робіть новий коміт як слід.
    • --mixed (те, що ви отримаєте, якщо режим не вказали) — «розібрати коміт назад до правок». Зміни повертаються в робочу директорію незастейдженими. Так само git reset без аргументів (тобто git reset HEAD) — це найшвидший спосіб розстейджити геть усе одразу.
    • --hard — «зробити так, ніби нічого не було». Гілка, індекс і файли на диску стають точно як у цільовому коміті. Все незакоммічене зникає. Це єдиний по-справжньому руйнівний режим — до нього ще повернемось окремим розділом.

    Типовий рятувальний сценарій для AQA: ви закомітили фікс автотесту в master, хоча мали — у фіча-гілку. git reset --soft HEAD~1 знімає коміт, лишаючи зміни застейдженими; далі створюєте гілку й комітите вже туди. Історія master чиста, робота ціла.

    commit --amend: полагодити останній коміт

    Окремий випадок — коли коміт правильний за суттю, але зіпсований у дрібниці: одрук у повідомленні або забутий файл. git commit --amend замінює останній коміт новим.

    # Виправити тільки текст повідомлення останнього коміту
    git commit --amend -m "fix: стабілізувати очікування в login.spec"
    
    # Додати забутий файл до останнього коміту, повідомлення лишити
    git add tests/helpers/wait.ts
    git commit --amend --no-edit

    Важливо розуміти механіку: --amend не редагує коміт «на місці». Коміт у Git незмінний, тож Git створює новий коміт з новим хешем і перевішує на нього гілку, а старий стає недосяжним. Тобто це, по суті, переписування історії в мініатюрі. Наслідок практичний: --amend безпечний, лише поки коміт не запушений. Якщо ви заамендили те, що вже бачать колеги, локальна й віддалена історія розійдуться — і push пройде тільки з примусом, що ламає роботу іншим. Це підводить нас до головного правила теми.

    reset vs revert: чому публічну історію тільки revert

    Це — центральне питання глави і найчастіше на співбесіді. Обидві команди «скасовують коміт», але роблять це протилежно.

    • git reset пересуває вказівник гілки назад. Небажаний коміт зникає з історії гілки, ніби його не було. Історія переписується.
    • git revert створює новий коміт, який застосовує зворотні зміни до цільового. Небажаний коміт лишається в історії, а поверх з'являється ще один, що його скасовує. Історія доповнюється, а не переписується.

    git reset до A — історія переписується

    A

    B (втрачено)

    C (втрачено)

    git revert B — історія доповнюється

    A

    B (баг)

    C

    D — скасовує B

    git reset до A — історія переписується

    A

    B (втрачено)

    C (втрачено)

    git revert B — історія доповнюється

    A

    B (баг)

    C

    D — скасовує B

    Чому це вирішує, який інструмент брати? Бо у Git гілка — спільний ресурс. Коли ви запушили коміти, їх уже могли витягнути (pull) колеги і CI. Якщо ви приберете ці коміти через reset і зробите push --force, ваша історія розійдеться з їхньою: у них ці коміти ще є, у вас — уже ні. Наступний їхній pull або поверне «видалене» назад, або впаде конфліктом, або (найгірше) хтось затре чиюсь роботу. Це і є «золоте правило» Git, докладніше — у главі «Rebase, cherry-pick і переписування історії»: не переписуй історію, яку вже бачать інші.

    Звідси проста, майже механічна відповідь:

    • Коміт локальний, ще не запушений — можна reset (чи --amend, чи rebase). Історія ваша особиста, переписуйте вільно.
    • Коміт уже в спільній гілці (main, develop, будь-що запушене) — тільки revert. Ви не стираєте минуле, ви додаєте коректувальний коміт. Історія лишається несуперечливою для всіх.
    # Скасувати конкретний коміт у спільній гілці — з'явиться новий коміт-антидот
    git revert a1b2c3d
    
    # Скасувати діапазон
    git revert HEAD~3..HEAD

    Нюанс, який відрізняє сильного кандидата: revert merge-коміту потребує вказати, яку з батьківських гілок вважати основною (git revert -m 1 <хеш>), бо в merge-коміту їх дві. Тема мержів — у главі «Merge-конфлікти». І ще: revert не «видаляє слід» бага — і сам баг, і його скасування лишаються в історії. Для аудиту це плюс (видно, що і коли відкотили), а не недолік.

    stash: відкласти незавершене

    Ситуація: ви на середині правки, нічого не готове до коміту, і тут прилітає «терміново перевір фікс у гілці release». Перемкнути гілку з брудною робочою директорією Git часто не дасть, а комітити напівфабрикат не хочеться. Для цього є git stash — «полиця» (стек), куди складають незавершене.

    # Сховати незакомічені зміни (робоча директорія стає чистою, як у HEAD)
    git stash push -m "недороблений тест checkout"
    
    # Подивитися, що на полиці
    git stash list          # stash@{0}, stash@{1}, ...
    
    # Повернути найсвіжіший stash і прибрати його з полиці
    git stash pop
    
    # Те саме, але лишити копію на полиці
    git stash apply

    Механіка: stash бере зміни з робочої директорії й індексу, зберігає їх окремо (технічно — як приховані коміти) і повертає робочу директорію до стану HEAD. Ви перемикаєтесь, робите термінове, повертаєтесь і git stash pop накладає відкладене назад.

    Дві пастки. По-перше, за замовчуванням stash не бере невідстежувані (untracked) файли — новий файл, який ви ще не додавали, лишиться на місці й не сховається; щоб узяти і його, потрібен git stash -u. По-друге, stash — це стек, і pop знімає найсвіжіший елемент; якщо застешили кілька разів і забули, легко застосувати не той. Stash зручний для коротких перемикань, але не для довготривалого зберігання «на потім» — для такого краще звичайна гілка.

    reflog: страхувальна сітка

    Тепер найважливіше для спокою. Коли ви робите reset, --amend чи невдалий rebase, «зниклі» коміти насправді нікуди не діваються одразу. Git веде журнал переміщень HEADreflog (reference log). Кожен раз, коли HEAD або гілка кудись переходили, туди лягає запис.

    # Показати, де побував HEAD (згори — найсвіжіше)
    git reflog
    # a1b2c3d HEAD@{0}: reset: moving to HEAD~1
    # e4f5g6h HEAD@{1}: commit: додав перевірку тосту   <- ось "втрачений" коміт

    HEAD@{1} — це попереднє положення HEAD. Тобто щойно ви зрозуміли, що зайвого відкотили, ви бачите в reflog хеш того коміту й повертаєтесь на нього:

    # Відновити гілку на "втрачений" коміт
    git reset --hard e4f5g6h

    Ось чому паніка після reset --hard майже завжди зайва: доки коміт існував, його хеш є в reflog, і його можна дістати. Записи reflog не вічні — за замовчуванням досяжні тримаються близько 90 днів, недосяжні близько 30, — але цього з надлишком вистачає, щоб виправити помилку того ж дня. Головне обмеження — reflog локальний: він живе лише у вашому клоні репозиторію. На чужій машині вашого reflog немає, і свіжого клону теж.

    І тут — критична межа страхувальної сітки: reflog рятує коміти, але не рятує незакомічене. Правки, які жили лише в робочій директорії й ніколи не потрапляли навіть у staging, не мають хеша, тож у reflog їх немає. Саме тому наступний розділ — окремо.

    Пастка reset --hard

    git reset --hard — найпідступніша команда цієї глави: вона безповоротно знищує роботу і робить це тихо, без підтвердження. Незакомічене нищить і git restore над файлом, але там намір явний — ви самі назвали, що викинути; а --hard виглядає як невинний відкат коміту. Небезпечний не сам відкат (коміти в reflog), а те, що --hard заразом затирає незакомічені зміни в робочій директорії й індексі. А їх, як ми з'ясували, не версіоновано — тож жоден reflog їх не поверне.

    Класичний сценарій втрати: ви півгодини правили тест, нічого не комітили, потім вирішили «почати начисто» й зробили git reset --hard HEAD. Коміт HEAD на місці — а ваші півгодини зникли назавжди. Виглядає як «безпечний відкат до останнього коміту», а насправді — безшумне видалення всього незбереженого.

    Практична гігієна:

    • Перед reset --hard завжди git status — переконайтесь, що в робочій директорії немає нічого, що шкода втратити.
    • Якщо є навіть підозра, що зміни потрібні, — спершу git stash (або звичайний коміт «чернетка»), і лише потім експериментуйте. Stash повертається, --hard — ні.
    • --hard над запушеним комітом — це ще й переписування спільної історії з усіма наслідками з розділу про revert.

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

    Виглядає як «прибрав один файл»: git checkout . мав скасувати правку в одному файлі, а зніс усі незбережені зміни. Насправді і старий checkout -- ., і git restore . без імені файла діють на всю робочу директорію. Скасовуйте адресно: git restore <конкретний файл>.

    Виглядає як «зробив reset, і коміт пропав назавжди». Насправді reset лише пересунув вказівник гілки; коміт живий і його хеш є в git reflog (протягом десятків днів). Панікувати не треба — треба подивитись reflog і зробити reset --hard <хеш> назад.

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

    Виглядає як «застешив і перемкнувся — усе збережено». Насправді git stash за замовчуванням не бере невідстежувані файли. Новий, ще не доданий файл лишиться в робочій директорії; для нього потрібен git stash -u.

    Виглядає як «--amend просто підправить повідомлення». Насправді --amend створює новий коміт з новим хешем. Якщо старий коміт уже запушений, історія розійдеться, і push пройде лише з --force — на спільній гілці цього робити не можна.

    Підсумок

    • reset рухає вказівник гілки; що станеться зі staging і файлами — вирішує режим: --soft (лишає застейдженим) → --mixed (лишає в робочій директорії) → --hard (знищує незакомічене).
    • reset і --amend переписують історію — це безпечно лише для локальних, ще не запушених комітів. Для скасування коміту в спільній гілці існує лише revert, який додає коміт-антидот, а не стирає минуле.
    • Незакомічене не версіонується: git restore <файл> і git reset --hard знищують такі правки безповоротно — reflog їх не поверне, бо в них немає хеша.
    • reflog — локальний журнал переміщень HEAD; він рятує «втрачені» коміти після reset/amend/rebase протягом десятків днів. Перша реакція на «я загубив коміт» — не паніка, а git reflog.
    • stash тимчасово відкладає незавершене, щоб перемкнути гілку; це стек, і за замовчуванням він не бере untracked-файли.

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

    • «Чим reset відрізняється від revert? Коли який?» — головне питання теми. Інтерв'юер перевіряє не знання команд, а розуміння, що гілка спільна: чи усвідомлює кандидат, що reset переписує історію і на запушеному ламає роботу іншим, а revert безпечний, бо лише додає коміт.
    • «Поясни --soft, --mixed, --hard — перевірка моделі трьох зон. Сильна відповідь прив'язує кожен прапорець до того, які зони він чіпає, а не переказує наслідки напам'ять.
    • «Ти випадково зробив git reset --hard і втратив коміт. Дії?» — перевіряють, чи кандидат знає про reflog і чи не панікує. Бонус — уточнити: якщо втрачене було незакоміченим, reflog не допоможе.
    • «Закомітив у main замість фіча-гілки. Як виправити?» — очікують reset --soft HEAD~1 + новий коміт у правильній гілці (для локального стану), і згадку про revert, якщо вже запушено.
    • «Треба терміново перемкнути гілку, а є незавершені зміни.» — очікують git stash, і що кандидат згадає про -u для untracked.

    Червоний прапорець для інтерв'юера — коли кандидат пропонує push --force на спільну гілку як буденний інструмент, не згадуючи ризику для колег. Зелений — коли сам розрізняє «локальне, переписую вільно» і «спільне, тільки revert».

    Джерела