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 комітів, а в один.
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 має два види тегів, і різниця не косметична:
| Аспект | Lightweight | Annotated |
|---|---|---|
| Що це | Просто ref на коміт | Окремий git-обʼєкт |
| Метадані | Немає | Автор тегу, дата, повідомлення |
| Підпис (GPG) | Не можна | Можна (-s) |
| Створення | git tag v2.4.1 | git 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(). Текстово ці зміни в різних місцях — конфлікту немає. Але після мержу в коді є виклик методу, якого вже не існує → компіляція або тест падає. Кожна гілка окремо була консистентна; зламала їх саме комбінація.
Як це діагностувати. 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
Що таке git bisect і яку задачу він розвʼязує?
git bisect — це вбудований у git бінарний пошук по історії, який показує, на якому саме коміті щось зламалося. Типова ситуація: тиждень тому автотест був зелений, сьогодні червоний, а між цими станами кількасот комітів. Перебирати їх з кінця по одному — марна витрата годин. Bisect бере від вас один коміт, де перевірка ще проходила (good), і один, де вже падає (bad), ділить проміжок навпіл і чекаутить середину. Ви ганяєте тест, кажете good чи bad — і половина кандидатів відпадає за один прогін. За кілька ітерацій git виводить перший поганий коміт, і замість сотень дифів ви дивитеся рівно в один. Для AQA це головний спосіб не просто завести баг, а вказати, який коміт його породив.
Чому bisect знаходить винуватця за десяток прогонів, а не за двісті?
Бо це поділ навпіл, а не перебір. Лінійна перевірка з кінця в найгіршому разі коштує стільки прогонів, скільки комітів у діапазоні. Бінарний пошук щоразу викидає цілу половину, тому кількість кроків — це логарифм за основою два від кількості комітів: для двохсот виходить близько восьми прогонів, для тисячі — близько десяти. Саме тому інтервʼюер за словом «bisect» одразу слухає, чи розуміє кандидат логарифмічну природу пошуку, — це відрізняє «щось таке чув» від «щодня цим локалізую регресії».
Як зробити, щоб bisect сам ганяв тест, а не ви руками на кожному кроці?
Для цього є git bisect run <команда>: замість того, щоб на кожному кандидаті запускати перевірку вручну й натискати good/bad, ви віддаєте git команду, яка сама виносить вирок. Виглядає це як git bisect start HEAD v2.3.0 (спочатку bad, потім good) і далі git bisect run npx playwright test checkout.spec.ts. Git на кожному кроці чекаутить черговий коміт, запускає вашу команду й читає її код виходу, щоб зрозуміти, добрий коміт чи ні. Playwright і CodeceptJS при фейлі природно віддають ненульовий код, тож обгортка навіть не потрібна. Через кілька хвилин git сам напише, який коміт перший поганий, разом із його дифом. Це перетворює півдня ручного розкопування на одну команду й каву.
Що означають коди виходу для git bisect run?
git bisect run тлумачить результат команди саме за кодом виходу, а не за текстом у консолі. Нуль — коміт добрий (тест пройшов). Код 125 — особливий: «цей коміт неможливо оцінити», тобто його треба пропустити (наприклад, він не збирається). Будь-який інший ненульовий код у діапазоні від 1 до 127, крім 125, означає поганий коміт. Це важливо розуміти, коли пишете власний скрипт-оракул: якщо ваш враппер завжди повертає 1 навіть на «нема сенсу тестувати», git вважатиме такий коміт зламаним і зіпсує пошук. Для нетестованих проміжних комітів правильний код — саме 125 (або ручне git bisect skip).
Чому flaky-тест робить результат bisect недостовірним?
Бо бінарний пошук працює лише на монотонному зламі: має існувати чітка межа, до якої тест стабільно зелений, а після — стабільно червоний. Flaky-тест цю монотонність руйнує: він падає й проходить випадково, незалежно від коду. Якщо на «доброму» боці історії такий тест раптом дасть червоне (чи навпаки), git повірить хибному вироку й відкине не ту половину — і виведе на коміт, який ні до чого не причетний. Тому перед bisect тест-оракул треба зробити детермінованим: прибрати гонки, зайві очікування, залежність від порядку. Спершу стабільний оракул — потім пошук, інакше ви шукаєте винуватця не там.
Що показує git blame і які прапорці варто знати?
git blame відповідає на питання «хто востаннє торкався кожного рядка файлу»: навпроти кожного рядка він ставить коміт, автора й дату. Для AQA це швидкий спосіб зрозуміти, звідки взявся конкретний селектор або коли з тесту зникла перевірка. Корисні прапорці: -L 40,60 звужує вивід до діапазону рядків, -w ігнорує зміни, що торкнулися лише пробілів і відступів, -M простежує рядок крізь його переміщення в межах файлу, а -C — навіть крізь копіювання чи переміщення з інших файлів. Ці прапорці рятують, коли рядок фізично переїхав, але логічно лишився тим самим.
У чому головна пастка git blame і як її обійти?
Пастка в тому, що blame показує останнє торкання рядка, а не причину його появи. Хтось прогнав файл через новий форматер чи автолінтер — і тепер blame на кожному рядку вказує на цей косметичний коміт, ховаючи справжнього автора логіки. Виглядає, ніби «цей коміт зламав усе», а насправді він лише переставив лапки. Проти цього є .git-blame-ignore-revs — версіонований список хешів «шумних» комітів (масові реформати), які blame пропускає, якщо ввімкнути git config blame.ignoreRevsFile .git-blame-ignore-revs. Разом із -w це повертає blame до розмови про зміст, а не про пробіли.
Чим git log -S відрізняється від git log -G?
Обидва шукають не в повідомленнях комітів, а у вмісті дифів, але реагують на різне. -S (його називають pickaxe) спрацьовує, коли в коміті змінилася кількість входжень рядка-підрядка: тобто ловить момент, де фрагмент коду зʼявився або зник. -G показує будь-який коміт, у діфі якого є рядок, що матчить regex, — навіть якщо загальна кількість входжень не змінилась, наприклад рядок просто відредагували на місці. На практиці: -S "getByTestId('pay-btn')" відповідає на «коли цей селектор додали чи прибрали», а -G "getByTestId.*pay" — на «де взагалі чіпали цей патерн». Різниця тонка, і саме тому її люблять як питання на розуміння, а не на зазубрене.
Чим pickaxe (-S) відрізняється від git log --grep?
Це часта плутанина, на якій ловлять. --grep шукає підрядок у тексті повідомлень комітів — тобто в тому, що людина написала в git commit -m. -S (і -G) шукають у самому коді, у вмісті дифів. Якщо треба знайти, у якому коміті додали чи прибрали конкретний виклик функції, --grep безпорадний: він знайде коміт лише якщо автор випадково згадав цей виклик у меседжі. Правильний інструмент для пошуку за вмістом зміни — pickaxe -S або regex -G, а --grep лишіть для випадків «знайди коміт зі словом revert у описі».
Annotated проти lightweight тега — коли який і чому це не косметика?
Тег — це вказівник, назавжди прибитий до конкретного коміту (на відміну від гілки, що їде вперед). Різниця між двома видами тегів суттєва. Lightweight-тег — це фактично закладка: голий ref на коміт, без інформації, хто його поставив, коли й навіщо. Annotated-тег (git tag -a v2.4.1 -m "...") — це окремий git-обʼєкт із метаданими: імʼя таггера, дата, повідомлення, і його можна підписати GPG (-s). Канонічне правило: релізи тегують annotated, бо номер релізу мусить нести контекст і переживати час; lightweight лишають для тимчасових особистих позначок. Для AQA це прямий інструмент traceability: за v2.4.1 з баг-репорту ви миттєво чекаутите точний стан коду, у якому баг живий.
Чому буває «тег є в мене локально, а в GitHub його немає»?
Бо звичайний git push теги не відправляє — на відміну від комітів. Ви створили тег, він лежить у локальному репозиторії, але на remote його нема, поки не запушите явно: git push origin v2.4.1 для одного тега або git push --tags для всіх. Це не баг git і не збій мережі, а забутий крок. Симптом упізнаваний: білд-система чи деплой «не бачать» реліз, хоча в терміналі тег очевидно є. Перше, що варто перевірити в такій ситуації, — чи тег узагалі доїхав на сервер.
Навіщо git describe і що означає рядок v2.4.1-14-gab12cd3?
git describe будує людиночитабельну назву поточного стану, відштовхуючись від найближчого annotated-тега. Рядок v2.4.1-14-gab12cd3 читається так: від тега v2.4.1 зроблено ще 14 комітів, а поточний коміт має скорочений хеш ab12cd3 (літера g попереду — просто позначка «git»). Це пряма відповідь на питання «а яка версія зараз зібралася?». Багато білд-систем зашивають вивід git describe у застосунок, тож на сторінці About чи в API-відповіді видно точний коміт — і в тесті ви можете звірити, ту саму збірку перевіряєте чи ні. Для цього потрібні саме annotated-теги: за lightweight describe за замовчуванням не чіпляється.
Що таке git-хуки і які з них найкорисніші для AQA?
Git-хуки — це скрипти, які git автоматично запускає в певні моменти життєвого циклу. Ідея для тестувальника проста: зловити проблему на машині розробника за секунди, а не в CI за двадцять хвилин, коли червоний пайплайн уже блокує команду, — це shift-left у чистому вигляді. Найкорисніші клієнтські хуки: pre-commit спрацьовує перед створенням коміту, і ненульовий код виходу коміт скасовує — сюди вішають лінтер і форматер на застейджені файли. commit-msg валідує повідомлення коміту, наприклад формат Conventional Commits. pre-push біжить перед відправкою на remote, і туди виносять довші перевірки: typecheck, юніт-тести, іноді швидкий смоук. Правило поділу — швидке в pre-commit, повільніше в pre-push.
Навіщо husky, якщо git уже вміє хуки з коробки?
Бо «голі» git-хуки живуть у теці .git/hooks/, а вона не комітиться: у git немає штатного способу роздати хуки разом із репозиторієм. У підсумку хук є на вашій машині, а в колеги його нема — і гарантія перетворюється на «кому пощастило налаштувати». Husky закриває саме цю дірку: він тримає хуки у версіонованій теці .husky/ у корені репо й підключає їх через core.hooksPath, тож звичайний npm install розкочує однакові хуки всім у команді. Типова звʼязка для тест-репозиторію — husky плюс lint-staged, щоб лінтер біг лише на застейджених файлах, а не на всьому проєкті щоразу.
Навіщо pre-commit хук, якщо є CI, і чи він щось гарантує?
Хук дає швидкий локальний фідбек: ви бачите проблему до пушу, а не через двадцять хвилин у пайплайні. Але — і це те, що відрізняє middle від junior — хук це зручність, а не бар'єр. Будь-хто пройде повз нього прапорцем git commit --no-verify чи git push --no-verify, а на чужому форку хуків може взагалі не бути. Тому справжній інваріант забезпечує сервер: branch protection, обовʼязкові статус-чеки, той самий лінт і ті самі тести в CI, які не обійти локальним прапорцем. Формула коротка: хуки економлять ваш час, гейти в CI гарантують правило. Одне не заміняє інше, і плутати їх — типова помилка на співбесіді.
Що таке семантичний конфлікт і чому «зелений злиток двох зелених гілок» не гарантований?
Семантичний (логічний) конфлікт — це коли дві гілки, кожна зелена в CI окремо, після мержу дають червоний тест, хоча текстового конфлікту git не побачив і маркерів <<<<<<< не було. Код зійшовся синтаксично, а зміст поламався. Класика: гілка A перейменувала метод getUser() на fetchUser() й оновила всі відомі їй виклики; гілка B, відгалужена раніше, додала новий тест, що кличе старий getUser(). Текстово зміни в різних місцях — злиття чисте, але в результаті є виклик методу, якого вже не існує, і воно падає. Кожна гілка була консистентна сама по собі; зламала їх саме комбінація. Висновок для AQA: злиття двох зелених гілок не зобовʼязане бути зеленим, і це треба ловити.
Як діагностувати семантичний конфлікт і як не допускати його процесом?
Діагностика зшиває всю главу в один ланцюжок. git bisect тут виведе на сам merge-коміт як перший поганий — і це коректно, бо винна не одна сторона, а їх злиття. Далі git show <merge> показує, що саме зійшлося, git log -S "getUser" — де зник виклик, який лишився в тесті, а git blame на рядку тесту вкаже гілку-автора застарілого виклику. Три інструменти дають відповідь за хвилини. Профілактика — процесна: вимога «гілка має бути актуальною відносно бази перед мержем» (у GitHub це Require branches to be up to date before merging) або merge queue змушують CI прогнатись саме на результаті злиття, а не на гілці окремо, — і семантичний конфлікт червоніє ще до потрапляння в майстер.
Три кейси, де git перетворює «тест червоний, і незрозуміло чому» на конкретну відповідь: автоматичний bisect, що сам знаходить коміт-винуватця; розбір семантичного конфлікту після мержа, який зшиває bisect, pickaxe і blame в один ланцюжок; і локальний захисний контур husky + lint-staged разом із перевіркою версії білда через git describe. Скрізь — які команди й що дивитися.
Кейс 1. git bisect run: хай git сам знайде винуватця
checkout.spec.ts був зелений на релізі v2.3.0, а на сьогоднішньому HEAD падає. Між ними — понад сотня комітів. Замість ручного перебору віддаємо пошук git-у: він на кожному кандидаті прогонить сам тест і читатиме код виходу.
git bisect start HEAD v2.3.0 # порядок: <bad> <good>
git bisect run npx playwright test checkout.spec.ts
Git чекаутить середній коміт, запускає Playwright, за кодом виходу вирішує good/bad і звужує діапазон. Через кілька прогонів отримуємо приблизно такий фінал:
running npx playwright test checkout.spec.ts
...
ab12cd3f9e is the first bad commit
commit ab12cd3f9e
Author: Ivan Petrenko
Date: Tue Jun 9 14:20:11 2026 +0300
refactor: rename pay-btn testid to submit-payment
1 file changed, 3 insertions(+), 3 deletions(-)
git bisect reset
Що дивитися і чому:
- Порядок аргументів у
start— спершу bad, потім good.git bisect start HEAD v2.3.0означає «поточний HEAD зламаний,v2.3.0ще робочий». Переплутаєте місцями — git шукатиме винуватця не в тій половині історії. runтлумачить результат за кодом виходу, не за текстом.0— коміт добрий,125— «неможливо оцінити, пропусти» (наприклад, білд посередині не збирається), будь-який інший код1–127— поганий. Тому не пишіть у власному врапперіexit 1на випадок «нема сенсу тестувати» — git визнає коміт зламаним і зіпсує пошук; для такого —exit 125.- Наприкінці не забудьте
git bisect reset. Поки bisect активний, ви в «відчепленому» стані на проміжному коміті.resetповертає на вихідний HEAD;git bisect runкличе його сам, а після ручного циклу це на вас. - Якщо винуватцем виявився merge-коміт — це не збій bisect. Так виглядає семантичний конфлікт (кейс 2): жодна зі сторін окремо тест не ламала, зламало саме злиття.
Передумова, без якої все марно: оракул має бути детермінованим. Якщо checkout.spec.ts флакі, один випадковий зелений прогін відправить git не в ту половину — спершу приберіть флак, потім запускайте bisect.
Кейс 2. Семантичний конфлікт: зелений злиток двох зелених гілок
Дві гілки пройшли CI кожна окремо, їх змержили — і на майстрі впав тест, якого жодна гілка не чіпала. Конфліктних маркерів не було: git не побачив текстового зіткнення. Це семантичний конфлікт — код зійшовся синтаксично, а зміст поламався.
Розкладка типова. Гілка A перейменувала метод і оновила всі відомі їй виклики:
// було
export function getUser(id: string) { /* ... */ }
// стало (гілка A)
export function fetchUser(id: string) { /* ... */ }
Гілка B, відгалужена до цього перейменування, додала новий тест зі старою назвою:
// гілка B, доданий тест
test('user profile loads', async () => {
const user = await getUser('42'); // методу вже не існує після мержа
expect(user.name).toBeDefined();
});
Текстово зміни в різних місцях — злиття чисте. Але після мержа в коді є виклик getUser, якого вже нема, і компіляція чи тест падає. Діагностика — три команди глави підряд:
# 1) хто зламав: bisect виводить на сам merge-коміт
git bisect start HEAD v2.4.0
git bisect run npx tsc --noEmit
# => <merge-hash> is the first bad commit
# 2) що зійшлося в цьому мержі
git show <merge-hash>
# 3) де зник виклик, який лишився в тесті (pickaxe, не --grep!)
git log -S "getUser(" --oneline
# 4) хто автор застарілого рядка в тесті
git blame -w -M tests/profile.spec.ts
Що дивитися і чому:
- bisect коректно вкаже на merge-коміт, і це правильна відповідь. Винна не гілка A й не гілка B, а факт їх злиття — перший поганий коміт саме merge. Не шукайте «справжнього» винуватця серед батьків: його там немає.
- Шукати зниклий виклик треба через
-S, а не--grep.--grepпорпається в повідомленнях комітів іgetUserу коді не знайде.-S "getUser("показує саме коміт, де кількість входжень цього фрагмента змінилася, — тобто де перейменування прибрало виклик. git blameтут відповідає «звідки взявся застарілий виклик у тесті». З прапорцями-w(без шуму пробілів) і-M(крізь переміщення рядка) він вкаже гілку B як автора рядка, що кличе неіснуючий метод.- Профілактика — процес, не героїзм. Увімкнена вимога Require branches to be up to date before merging або merge queue змусили б CI прогнатись на результаті злиття, і цей тест почервонів би до потрапляння в майстер.
Кейс 3. husky + lint-staged і git describe як оракул версії
Локальний контур ловить проблему за секунди до пушу, а перевірка версії білда рятує від прогону всього сюїта по застарілому фронтенді. Хуки тримаємо у версіонованій теці .husky/, щоб вони приїхали всім через npm install, а не жили тільки на одній машині.
# .husky/pre-commit — швидке, лише на застейджених файлах
npx lint-staged
# .husky/pre-push — довше: типи й швидкий смоук
npx tsc --noEmit && npx playwright test --grep @smoke
lint-staged конфігурують так, щоб лінтер біг не по всьому проєкту, а лише по тому, що йде в коміт:
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}
}
Оракул свіжості в тесті — звірити, що ми взагалі на актуальній збірці. Багато білд-систем зашивають вивід git describe у застосунок, тож версію видно в API чи на сторінці About:
import { test, expect } from '@playwright/test';
test('перевіряємо очікувану збірку до основних асертів', async ({ request }) => {
const res = await request.get('https://app.example.com/api/version');
const { build } = await res.json(); // напр. "v2.4.1-14-gab12cd3"
// старий тег у відповіді = деплой не доїхав, далі ганяти сюїт немає сенсу
expect(build).toMatch(/^v2\.4\.1-/);
});
Що дивитися і чому:
.husky/комітиться,.git/hooks/— ні. Саме тому husky підключає хуки черезcore.hooksPathдо версіонованої теки:npm installрозкочує однакові хуки всій команді, а не тільки тому, хто налаштував.lint-stagedекономить хвилини на кожному коміті. Лінт лише застейджених файлів замість усього проєкту — різниця між «pre-commit непомітний» і «pre-commit, який хочеться обійти».- Хук — не гарантія. Цей самий
tsc --noEmitі смоук мають дублюватися в CI за branch protection, боgit commit --no-verifyпройде повз.husky/за секунду. Локальний контур — про швидкість, серверний — про інваріант. git describeдає annotated-теги. За lightweight-тегомdescribeза замовчуванням не чіпляється, тож щоб версія в білді була осмислена (v2.4.1-14-g...), релізи треба тегувати саме annotated і не забувати запушити тег на remote.
git bisect: пошук винуватця
- Можу пояснити, що
git bisect— це бінарний пошук по історії, а не перебір, тож коштує близькоlog2(N)прогонів, а неN. - Розумію, що
git bisect run <команда>автоматизує пошук за кодом виходу тесту, і Playwright/CodeceptJS віддають ненульовий код при фейлі природно. - Знаю коди виходу для
run:0— good,125— skip (не тестується), решта1–127крім125— bad. - Розумію, чому bisect вимагає монотонного зламу й чому flaky-тест дає хибний вирок — спершу детермінований оракул, потім пошук.
blame і pickaxe: хто і навіщо
- Розумію, що
git blameпоказує останнє торкання кожного рядка (коміт, автор, дата), а не причину його появи, і знаю прапорці-w,-M,-Cпроти шуму. - Можу пояснити, як
.git-blame-ignore-revsховає «шумні» коміти масових реформатів, щоб blame показував автора логіки. - Знаю різницю
-Svs-G:-Sловить зміну кількості входжень (поява/зникнення рядка),-G— будь-який діф, що матчить regex. - Можу пояснити, чим
-S/-Gвідрізняються від--grep: перші шукають у вмісті дифу,--grep— лише в тексті повідомлень комітів.
Теги: annotated vs lightweight
- Знаю різницю: lightweight — голий ref-закладка без метаданих; annotated — окремий обʼєкт із таггером, датою, повідомленням і можливістю підпису.
- Можу пояснити, чому релізи тегують annotated, а lightweight лишають для тимчасових особистих позначок.
- Памʼятаю, що теги не пушаться разом із
git push— потрібен явнийgit push origin <tag>або--tags. - Розумію вивід
git describe(v2.4.1-14-gab12cd3) як оракул версії в пайплайні й що він чіпляється саме за annotated-теги.
Хуки й husky: shift-left локально
- Знаю призначення ключових хуків: pre-commit (лінт/формат на застейджених), commit-msg (валідація повідомлення), pre-push (typecheck, юніти, смоук).
- Розумію, чому
.git/hooks/не роздається з репо і як husky через версіоновану.husky/таcore.hooksPathце виправляє. - Можу пояснити звʼязку husky +
lint-staged— перевірка лише застейджених файлів, а не всього проєкту. - Памʼятаю головне застереження: хук — це зручність, а не бар'єр; його обходять
--no-verify, а гарантію дає branch protection у CI.
Alias-и та семантичний конфлікт
- Розумію, що alias у
git configзнижує поріг використання рідкісних команд, а alias з!виконує довільний shell. - Можу пояснити семантичний конфлікт: обидві гілки зелені окремо, злиток червоний, текстового конфлікту не було.
- Знаю, що
git bisectу такому разі коректно вкаже на merge-коміт, а не на одну зі сторін. - Можу назвати профілактику процесом: актуальність гілки до бази (Require branches to be up to date) або merge queue, щоб CI біг на результаті злиття.
Скільки приблизно прогонів витратить git bisect на діапазоні з 1000 комітів?
Питання
git bisect — що це й навіщо AQA?