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

    09 · Git і CI/CD

    Git для AQA: bisect, blame, хуки й теги

    Зміст

    Базовий цикл clone → commit → push — це те, що git уміє для всіх. Але AQA стикається з git під іншим кутом: коли автотест раптом почервонів, треба не додати коміт, а знайти той, що зламав систему. Коли локатор перестав чіплятися — зрозуміти, хто й навіщо перейменував атрибут. Коли баг репортять «на релізі 2.4.1» — знати, який саме білд це був. І коли червоний пайплайн блокує всю команду — зловити помилку ще до пушу, а не в CI через двадцять хвилин.

    Ця глава — про той шар git, який перетворює тестувальника з «того, хто заводить баги» на того, хто локалізує причину. Інструменти тут не найчастіші на щодень, але саме вони економлять години ручного розкопування історії, і саме про них люблять питати на співбесіді, щоб відрізнити «комітю в майстер» від «розумію, як працює контроль версій». Це також частина керування конфігурацією (configuration management) у термінах ISTQB — traceability між версією коду, білдом і результатом тесту.

    git bisect: бінарний пошук винуватця

    Ситуація: тест checkout.spec.ts зелений тиждень тому й червоний сьогодні. Між цими точками — 200 комітів. Читати кожен діф — божевілля. Перевіряти по одному з кінця — лінійно, до 200 прогонів. git bisect розвʼязує це бінарним пошуком: якщо ви знаєте один добрий (good) коміт, де тест проходив, і один поганий (bad), де падає, git ділить діапазон навпіл і питає вас про середній коміт. Ви відповідаєте good/bad — і половина історії відпадає. Далі знову навпіл. Для 200 комітів це не 200 перевірок, а близько восьми (log₂200 ≈ 7.6).

    Механізм важливіший за команди: bisect працює лише тому, що злам монотонний — до якогось коміту тест зелений, після нього стабільно червоний. Якщо баг «мерехтить» (flaky), бінарний пошук збреше: git відкине правильну половину через випадковий зелений прогон. Тому перед bisect тест-оракул має бути детермінованим (про боротьбу з флаком у CI — /git-ci/avtotesty-v-ci-stabilnist-i-debah).

    Ручний цикл:

    git bisect start
    git bisect bad                 # поточний HEAD зламаний
    git bisect good v2.3.0         # тут тест ще проходив
    # git чекаутить коміт посередині — ви запускаєте тест і кажете:
    git bisect good                # або: git bisect bad
    # ...повторюється ~log2(N) разів...
    # git bisect good abc1234 is the first bad commit
    git bisect reset               # повернутися до вихідного HEAD

    Якщо якийсь коміт не збирається чи не тестується (наприклад, зламаний білд посередині) — його не можна оцінити, тому кажуть git bisect skip, і git обирає сусідній.

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

    git bisect start HEAD v2.3.0   # <bad> <good> одним рядком
    git bisect run npx playwright test checkout.spec.ts

    git bisect run запускає команду на кожному кандидаті й читає її код виходу: 0 — коміт добрий, 125 — пропустити (не тестується), будь-який код 1–127 крім 125 — поганий. Playwright і CodeceptJS повертають ненульовий код при фейлі природно, тож ланцюжок працює без обгортки. За кілька хвилин git сам виведе <hash> is the first bad commit разом із діфом — і ви дивитесь уже не в 200 комітів, а в один.

    0 = good

    1..127 крім 125 = bad

    125 = skip

    ні

    так

    good: v2.3.0
    bad: HEAD

    git чекаутить
    середній коміт

    Запуск тесту
    exit code

    винуватець у новішій
    половині

    винуватець у старішій
    половині або це він

    взяти сусідній коміт

    залишився 1 коміт?

    first bad commit

    0 = good

    1..127 крім 125 = bad

    125 = skip

    ні

    так

    good: v2.3.0
    bad: HEAD

    git чекаутить
    середній коміт

    Запуск тесту
    exit code

    винуватець у новішій
    половині

    винуватець у старішій
    половині або це він

    взяти сусідній коміт

    залишився 1 коміт?

    first bad commit

    blame і log -S: хто і навіщо змінив рядок

    git bisect каже який коміт. git blame каже, хто востаннє торкався кожного рядка файлу:

    git blame tests/checkout.spec.ts
    # ^a1b2c3d (Ivan Petrenko 2026-06-14 42)   await page.getByTestId('pay-btn').click();

    Кожен рядок отримує коміт, автора й дату. Для AQA це швидка відповідь на «звідки взявся цей селектор» чи «коли зникла ця перевірка». Корисні прапорці: -L 40,60 обмежує діапазоном рядків, -w ігнорує зміни лише в пробілах, -M простежує рядок крізь переміщення в межах файлу, а -C — крізь копіювання чи переміщення з інших файлів.

    Головна пастка blame — він показує останню зміну рядка, а не причину. Хтось перевів увесь файл на нове форматування — і blame на кожному рядку вказує на цей косметичний коміт, а не на того, хто написав логіку. Проти цього є .git-blame-ignore-revs: список хешів «шумних» комітів (масові реформати), які blame пропускає, якщо ввімкнути git config blame.ignoreRevsFile .git-blame-ignore-revs.

    Коли важливий не рядок, а рядок тексту, що зʼявився чи зник в історії, — використовують «pickaxe»:

    git log -S "getByTestId('pay-btn')" --oneline    # коміти, де змінилась кількість входжень рядка
    git log -G "getByTestId.*pay"        --oneline    # коміти, чий діф містить рядок за regex
    git log -L 40,60:tests/checkout.spec.ts           # еволюція діапазону рядків 40–60

    Різниця між -S і -G тонка, але її люблять на співбесіді: -S реагує, коли кількість входжень підрядка змінилась (додали чи прибрали), тобто ловить появу/зникнення. -G показує будь-який коміт, у діфі якого є рядок, що матчить regex, — навіть якщо загальна кількість не змінилась (наприклад, рядок відредагували). -S відповідає на «коли цей селектор зʼявився/зник», -G — на «де взагалі торкались цього патерну».

    Теги: annotated vs lightweight

    Гілка рухається за кожним новим комітом. Тег (tag) — навпаки, назавжди прибитий вказівник на конкретний коміт. Саме тому теги — стандартний спосіб позначити реліз: v2.4.1 завжди вказує на той самий стан коду, і за номером з баг-репорту ви миттєво чекаутите точний білд, у якому баг живий.

    Git має два види тегів, і різниця не косметична:

    АспектLightweightAnnotated
    Що цеПросто ref на комітОкремий git-обʼєкт
    МетаданіНемаєАвтор тегу, дата, повідомлення
    Підпис (GPG)Не можнаМожна (-s)
    Створенняgit tag v2.4.1git tag -a v2.4.1 -m "..."
    ПризначенняТимчасова локальна позначкаРеліз, який лишається в історії

    Lightweight-тег — це фактично закладка, що не рухається: ні хто поставив, ні коли, ні навіщо. Annotated-тег — повноцінний обʼєкт із таггером, датою й повідомленням, який можна підписати. Канонічне правило: релізи тегуються annotated, бо вони несуть метадані й переживають час; lightweight лишають для одноразових особистих позначок.

    Практичний нюанс: теги не пушаться автоматично з git push. Треба явно git push origin v2.4.1 (або --tags). Тому «на моїй машині тег є, а в GitHub нема» — не баг, а забутий пуш.

    Ще один інструмент AQA — git describe. Він бере найближчий annotated-тег і будує людиночитабельну назву поточного стану:

    git describe
    # v2.4.1-14-gab12cd3   → 14 комітів після тегу v2.4.1, поточний хеш ab12cd3

    Це відповідь на «а яка версія зараз зібралась?» прямо в пайплайні. Багато білд-систем зашивають вивід git describe у застосунок, тож на сторінці About чи в API-відповіді видно точний коміт — і ви завжди знаєте, ту саму версію тестуєте чи ні. Семантика номерів (major.minor.patch) — тема глави про релізи (/git-ci/seredovyshcha-deploi-i-relizy).

    Хуки: pre-commit, pre-push і husky

    Git-хуки (git hooks) — це скрипти, які git запускає в певні моменти. Ідея для AQA проста: зловити проблему на машині розробника за секунди, а не в CI за двадцять хвилин, коли червоний пайплайн уже блокує всю команду. Це shift-left у чистому вигляді.

    Найкорисніші клієнтські хуки:

    • pre-commit — перед створенням коміту. Ненульовий код виходу скасовує коміт. Сюди вішають лінтер і форматер на застейджені файли, швидкі перевірки.
    • commit-msg — валідує повідомлення коміту (наприклад, формат Conventional Commits).
    • pre-push — перед відправкою на remote. Сюди — те, що довше: typecheck, юніт-тести, іноді швидкий смоук.

    Проблема «голих» git-хуків: вони живуть у .git/hooks/, а ця тека не комітиться. Тобто хук на вашій машині є, а в колеги нема — у git немає штатного способу роздати хуки з репозиторієм. Саме цю дірку закриває husky: він тримає хуки у версіонованій теці .husky/ у корені репо й підключає їх через core.hooksPath, тож npm install розкочує однакові хуки всім.

    Типова зв'язка для фронтенд/тест-репо — husky + lint-staged (лінтер лише на застейджених файлах, а не на всьому проєкті):

    # .husky/pre-commit
    npx lint-staged
    
    # .husky/pre-push
    npx tsc --noEmit && npx playwright test --grep @smoke

    Критичне застереження, яке відрізняє middle від junior: хуки — це зручність, а не бар'єр. Будь-хто пройде повз них прапорцем git commit --no-verify (або git push --no-verify), а на чужому форку їх узагалі може не бути. Тому справжнє забезпечення якості — на сервері: branch protection, обовʼязкові статус-чеки, той самий лінт і тести в CI (/git-ci/pull-request-i-stratehii-hilkuvannia). Хуки економлять ваш час, гейти в CI гарантують інваріант. Одне не заміняє інше.

    Alias-и

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

    git config --global alias.st  status
    git config --global alias.lg  "log --oneline --graph --decorate --all"
    git config --global alias.last "log -1 HEAD --stat"
    git config --global alias.who "blame -w -M"

    Тепер git lg малює граф історії, git who <файл> — розумний blame без шуму пробілів. Alias, що починається з !, виконує довільну shell-команду, тож можна загорнути навіть увесь bisect-прогін у git find-broken. Головна цінність — знизити поріг використання рідкісних, але потужних команд: коли git bisect run схований за одним аліасом, ви застосовуєте його частіше, ніж «колись потім розберуся».

    Кейс: тест упав після мержа

    Найпідступніший сценарій, який зшиває всю главу. Дві гілки, обидві зелені в CI поодинці. Їх мержать — і на майстрі падає тест, якого жодна гілка не ламала. Merge пройшов без конфліктних маркерів: git не побачив текстового зіткнення. Це семантичний (логічний) конфлікт — код зійшовся синтаксично, а зміст поламався.

    Класичний приклад: гілка A перейменувала метод getUser() на fetchUser() й оновила всі відомі їй виклики. Гілка B, відгалужена раніше, додала новий тест, що кличе старий getUser(). Текстово ці зміни в різних місцях — конфлікту немає. Але після мержу в коді є виклик методу, якого вже не існує → компіляція або тест падає. Кожна гілка окремо була консистентна; зламала їх саме комбінація.

    main: getUser

    Гілка A
    getUser → fetchUser
    CI зелений

    Гілка B
    + тест кличе getUser
    CI зелений

    merge commit
    текстово чисто

    тест падає:
    getUser не існує

    main: getUser

    Гілка A
    getUser → fetchUser
    CI зелений

    Гілка B
    + тест кличе getUser
    CI зелений

    merge commit
    текстово чисто

    тест падає:
    getUser не існує

    Як це діагностувати. git bisect тут виведе на сам merge-коміт як перший поганий — і це правильна відповідь: винна не одна сторона, а їх злиття. Далі git show <merge> покаже, що зійшлося, а git log -S "getUser" — де саме зник виклик, який лишився в тесті. git blame на рядку тесту вкаже гілку B як автора застарілого виклику. Три інструменти глави складаються в одну відповідь за хвилини.

    Як це не допускати процесом: вимога «гілка має бути актуальною відносно бази перед мержем» (у GitHub — Require branches to be up to date before merging) або merge queue змушують CI прогнатись саме на результаті злиття, а не на гілці окремо, — і семантичний конфлікт червоніє до того, як потрапить у майстер. Текстова природа merge-конфліктів — в окремій главі (/git-ci/merge-konflikty-prychyny-i-rozviazannia); тут ключове — що «зелений злиток зелених гілок» не гарантований, і git дає інструменти це ловити.

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

    • Виглядає як «bisect бреше», а насправді тест флакі. Бінарний пошук вимагає монотонності: до коміту зелено, після — стабільно червоно. Один випадковий зелений прогон на flaky-тесті відправляє git не в ту половину. Спершу зробіть оракул детермінованим, потім bisect.
    • Виглядає як «цей коміт зламав рядок», а насправді blame вказує на реформат. blame показує останнє торкання, не причину. Масовий переформат затирає авторство логіки — використовуйте .git-blame-ignore-revs і -w.
    • Виглядає як «тег є, а деплой його не бачить», а насправді тег не запушений. git push не відправляє теги. Потрібен явний git push origin <tag> або --tags.
    • Виглядає як «хуки гарантують якість», а насправді їх обходять --no-verify. Клієнтські хуки — порада, не примус. Інваріант забезпечує CI і branch protection, не .husky/.
    • Виглядає як «баг у застосунку після мержу», а насправді семантичний конфлікт. Обидві гілки зелені, злиток червоний, текстового конфлікту не було. bisect вкаже на merge-коміт — і це коректно.
    • Виглядає як «git log --grep знайде де змінили код», а насправді --grep шукає в повідомленнях комітів. Щоб шукати за вмістом діфу — -S (pickaxe) або -G (regex), не --grep.

    Підсумок

    • git bisect — бінарний пошук коміту-винуватця за log₂(N) кроків; git bisect run <тест> автоматизує його за кодом виходу. Працює лише на детермінованому (не flaky) оракулі.
    • git blame каже, хто востаннє змінив рядок (але не завжди навіщо); git log -S/-G знаходить коміт, де рядок коду зʼявився чи зник, — на відміну від --grep, що шукає в меседжах.
    • Annotated-теги несуть автора/дату/повідомлення й підпис — ними тегують релізи; lightweight — гола закладка. Теги не пушаться самі.
    • Git-хуки (pre-commit/pre-push, версіоновані через husky) ловлять проблему локально й дешево, але їх обходять --no-verify; гарантію дає CI, не хук.
    • «Зелений злиток двох зелених гілок» не гарантований: семантичний конфлікт червоніє на merge-коміті, і bisect його знаходить.

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

    • «Автотест почав падати, між робочою й зламаною версією сотні комітів — як знайдеш винуватця?» Очікують назву git bisect, розуміння, що це бінарний пошук (log, не лінійно), і бонусом — git bisect run зі скриптом-тестом. Інтервʼюер дивиться, чи кандидат не полізе читати діфи руками.
    • «Чим git blame відрізняється від git log -S blame — хто торкався рядка зараз; pickaxe — у якому коміті рядок додали/прибрали в історії. Перевіряють, чи людина розуміє, що blame показує останню зміну, а не причину.
    • «Annotated vs lightweight тег — коли який?» Ключове — annotated є окремим обʼєктом із метаданими й підписом, тому для релізів; lightweight — тимчасова позначка. Часто вплітають «а чому мій тег не приїхав на remote».
    • «Навіщо pre-commit хук, якщо є CI? І чи він щось гарантує?» Сильна відповідь: хук — це швидкий локальний фідбек (shift-left), але не бар'єр, бо обходиться --no-verify; інваріант забезпечує branch protection у CI. Тут відсіюють тих, хто плутає зручність із гарантією.
    • «Обидві гілки зелені, після мержу тест червоний, конфлікту не було — що це і як шукати?» Семантичний конфлікт; діагностика через bisect (вкаже на merge-коміт) + blame/pickaxe; профілактика — актуальність гілки до бази / merge queue. Це питання на middle-зрілість.

    Джерела

    • Pro Git (Scott Chacon, Ben Straub), «Git Tools — Debugging with Git» (bisect, blame): git-scm.com/book/en/v2/Git-Tools-Debugging-with-Git
    • Pro Git, «Git Basics — Tagging» (annotated vs lightweight): git-scm.com/book/en/v2/Git-Basics-Tagging
    • Git Reference — githooks, git-bisect, git-blame, git-describe: git-scm.com/docs/githooks, git-scm.com/docs/git-bisect, git-scm.com/docs/git-blame, git-scm.com/docs/git-describe
    • husky — офіційна документація: typicode.github.io/husky
    • ISTQB CTFL 4.0 Syllabus, розділ 5.4 «Configuration Management» (version control і traceability): astqb.org/assets/documents/ISTQB_CTFL_Syllabus-v4.0.pdf