← Valtive QA Quiz quiz.valtive.io/notes/git-cicd valtive.io
📖 Про цей гайд📖 About this guide
Для когоWho it's for
Для QA інженерів, від яких на співбесіді чекають не тільки тестів, а й роботи з репозиторієм: гілка, PR, конфлікт, червоний білд. Підходить, якщо команди ти вже набираєш, але хочеш пояснити, що саме вони роблять — і що робити, коли пішло не так.
For QA engineers who are expected to talk not only about tests but about the repository too: a branch, a PR, a conflict, a red build. It fits if you already type the commands but want to explain what they actually do — and what to do when it goes wrong.
Як користуватисьHow to use it
Фільтруй питання по рівню — Junior, Middle або Senior. Відкривай картку, читай концепт і код. Потім закрий код і прочитай вголос з блоку 🗣 Як читати вголос — це тренує відповідь в живій розмові, а не тільки розуміння очима.
Filter the questions by level — Junior, Middle or Senior. Open a card, read the concept and the code. Then hide the code and say the answer out loud from the 🗣 How to say it out loud block — that trains the answer as a live conversation, not just recognition on the page.
Три базові якорі — з них випливає більшість відповідейThree anchors — most of the answers follow from them
ЯКІР 1 · Гілка — це вказівникANCHOR 1 · A branch is a pointer
Не копія проєкту, а файл з одним хешем. Звідси і дешеві гілки, і те, чому reset нічого не видаляє.
Not a copy of the project — a file holding a single hash. That is why branches are cheap, and why reset deletes nothing.
ЯКІР 2 · Приватне і спільнеANCHOR 2 · Private and shared
Історію, яку ще ніхто не забрав, переписувати можна. Ту, що вже в інших, — ні. У цьому вся різниця між rebase і revert.
History nobody has pulled yet can be rewritten. History that is already on other machines cannot. That is the whole difference between rebase and revert.
ЯКІР 3 · CI — це доказANCHOR 3 · CI is evidence
Зелений білд щось означає, тільки якщо він відтворюваний. Ре-ран до зеленого не лагодить нічого, а вчить команду не вірити CI.
A green build means something only if it is reproducible. Re-running until green fixes nothing and teaches the team not to trust CI.
—
питаньquestions
3
рівніlevels
—
voice-блокиvoice blocks
Git
GitHub Actions
📚 4 РОЗДІЛИ📚 4 SECTIONS
—
Junior
—
Middle
—
Senior
0 / 0 · розгорни картку, щоб зарахувати їїopen a card to count it
▶ 1 8
1 Робоча директорія, staging area і HEAD — три стани одного файлуThe working directory, the staging area and HEAD — one file in three states
ОсновиBasicsJunior▶

Git тримає не одну версію твого файлу, а три одночасно. Майже кожне «я нічого не розумію» на початку — це насправді плутанина між цими трьома.

Git doesn't keep one version of your file, it keeps three at the same time. Almost every "I don't understand any of this" at the beginning is really a mix-up between those three.

Робоча директорія (working tree)

Working directory (working tree)

Те, що ти реально бачиш у редакторі й що прочитає Playwright, коли запустиш тест. Звичайні файли на диску. Git про них знає, але поки що нічого з ними не зробив.

What you actually see in the editor, and what Playwright will read when you start a test. Ordinary files on disk. Git knows about them, but so far it hasn't done anything with them.

Staging area (вона ж index)

Staging area (also known as the index)

Проміжний список: «оце піде в наступний коміт». Ти кладеш туди зміни командою git add. Це не копія папки — це окремий файл .git/index, у якому Git тримає готовий до фіксації знімок.

An in-between list: "this is what goes into the next commit". You put changes there with git add. It isn't a copy of the folder — it's a separate file, .git/index, where Git keeps a snapshot ready to be recorded.

HEAD

HEAD

Вказівник на останній коміт твоєї поточної гілки. Простими словами — «як виглядав проєкт, коли я комітив востаннє». Це те, з чим Git порівнює все інше.

A pointer to the last commit on your current branch. In plain words — "what the project looked like the last time I committed". It's what Git compares everything else against.

Далі все просто: git add переносить зміну з робочої директорії в staging, git commit переносить весь staging у новий коміт, і HEAD переїжджає на нього.

After that it's simple: git add moves a change from the working directory into staging, git commit moves the whole staging area into a new commit, and HEAD moves onto it.

Чому це варто розуміти саме так: git status не показує «список змін». Він показує рівно два діфи. Верхній блок Changes to be committed — це різниця між HEAD і staging. Нижній блок Changes not staged for commit — різниця між staging і робочою директорією. Один і той самий файл може бути в обох блоках одночасно, і це не баг: ти застейджив одну правку, а потім дописав ще одну.

Why it's worth understanding it exactly this way: git status doesn't show a "list of changes". It shows exactly two diffs. The top block, Changes to be committed, is the difference between HEAD and staging. The bottom block, Changes not staged for commit, is the difference between staging and the working directory. The same file can be in both blocks at once, and that isn't a bug: you staged one edit and then wrote another.

Найчастіша пастка новачка: зробив git add, потім git diff — і порожньо. Зміни не зникли. Голий git diff показує staging проти робочої директорії, а після add вони збіглися. Щоб побачити, що саме піде в коміт, потрібен git diff --staged.

The most common beginner trap: you run git add, then git diff — and it's empty. The changes haven't gone anywhere. Bare git diff shows staging against the working directory, and after add the two match. To see what will actually go into the commit you need git diff --staged.

Схема: три стани і команди, що рухають зміни між ними
Diagram: the three states and the commands that move changes between them
   робоча директорія        staging (index)         HEAD
   те, що в редакторі       .git/index              останній коміт
  ┌────────────────┐      ┌────────────────┐    ┌────────────────┐
  │ checkout.spec  │      │ checkout.spec  │    │ checkout.spec  │
  │  (з новим      │      │  (як на момент │    │  (як було      │
  │   тестом)      │      │   git add)     │    │   в коміті)    │
  └────────────────┘      └────────────────┘    └────────────────┘
          │      git add          │     git commit      ▲
          └──────────────────────▶│────────────────────▶│
          ◀───────────────────────┘                     │
            git restore --staged                         │
          ◀─────────────────────────────────────────────┘
            git restore .   (затирає роботу — обережно)

  git diff            = staging ↔ робоча директорія  (що ще НЕ застейджено)
  git diff --staged   = HEAD ↔ staging               (що піде в коміт)
  git diff HEAD       = HEAD ↔ робоча директорія     (усе разом)
   working directory        staging (index)         HEAD
   what is in the editor    .git/index              last commit
  ┌────────────────┐      ┌────────────────┐    ┌────────────────┐
  │ checkout.spec  │      │ checkout.spec  │    │ checkout.spec  │
  │  (with the new │      │  (as of        │    │  (as it was    │
  │   test)        │      │   git add)     │    │   committed)   │
  └────────────────┘      └────────────────┘    └────────────────┘
          │      git add          │     git commit      ▲
          └──────────────────────▶│────────────────────▶│
          ◀───────────────────────┘                     │
            git restore --staged                         │
          ◀─────────────────────────────────────────────┘
            git restore .   (wipes your work — careful)

  git diff            = staging ↔ working directory  (what is NOT staged yet)
  git diff --staged   = HEAD ↔ staging               (what goes into the commit)
  git diff HEAD       = HEAD ↔ working directory     (everything together)
Той самий файл в обох блоках git status — це нормально
The same file in both git status blocks — that's normal
$ git add tests/checkout.spec.ts
$ # дописав ще один expect уже після add
$ git status

On branch feat/42-checkout-smoke
Changes to be committed:        ← HEAD ↔ staging
        modified:   tests/checkout.spec.ts

Changes not staged for commit:  ← staging ↔ робоча директорія
        modified:   tests/checkout.spec.ts

Untracked files:                ← Git цього файлу ще не бачив жодного разу
        pages/CheckoutPage.ts
$ git add tests/checkout.spec.ts
$ # added one more expect after the add
$ git status

On branch feat/42-checkout-smoke
Changes to be committed:        ← HEAD ↔ staging
        modified:   tests/checkout.spec.ts

Changes not staged for commit:  ← staging ↔ working directory
        modified:   tests/checkout.spec.ts

Untracked files:                ← Git has never seen this file before
        pages/CheckoutPage.ts
🔧 Команди й прапорці в цьому прикладі git add <шлях> — кладе поточний стан файлу в staging; git restore --staged <шлях> — прибирає зі staging, файл на диску не чіпає; git restore <шлях> — навпаки, затирає файл на диску вмістом зі staging і роботу не повертає; --staged у git diff і в git restore означає одне й те саме — «працюй з індексом, а не з диском».
🔧 Commands and flags in this example git add <path> — puts the file's current state into staging; git restore --staged <path> — takes it back out of staging and doesn't touch the file on disk; git restore <path> — the opposite: it overwrites the file on disk with whatever is in staging, and it does not give the work back; --staged means the same thing in git diff and in git restore — "work with the index, not with the disk".
🗣 Як читати вголос Git тримає файл одразу в трьох станах, і плутанина між ними — це джерело більшості початкових проблем. Робоча директорія — це те, що я бачу в редакторі й що реально прочитає тест при запуску. Staging area, вона ж index — це проміжний список того, що піде в наступний коміт, туди зміни потрапляють командою git add. HEAD — це вказівник на останній коміт поточної гілки, тобто знімок того, як проєкт виглядав, коли я комітив востаннє. З цього напряму випливає, що показує git status: верхній блок — це різниця між HEAD і staging, нижній — між staging і диском, і один файл цілком може бути в обох блоках, якщо я застейджив одну правку і дописав другу. І звідси ж класична пастка: після git add голий git diff показує порожньо, бо він порівнює staging з диском, а щоб побачити, що саме піде в коміт, треба git diff --staged.
🗣 How to say it out loud Git holds a file in three states at once, and mixing them up is where most of the early confusion comes from. The working directory is what I see in my editor and what a test actually reads when it runs. The staging area, the index, is the in-between list of what goes into the next commit, and things land there with git add. HEAD is a pointer to the last commit on my current branch, so it's basically a snapshot of how the project looked the last time I committed. Once you see that, git status stops being mysterious: the top block is HEAD versus staging, the bottom one is staging versus the disk, and the same file can honestly sit in both if I staged one edit and then wrote another. That's also where the classic trap comes from — after git add a bare git diff looks empty, because it compares staging with the disk. If I want to see what's really going into the commit, it's git diff --staged.
2 Які команди Git SDET реально набирає щодня, а які — раз на кварталWhich Git commands an SDET actually types every day, and which ones once a quarter
ОсновиBasicsJunior▶

У Git понад півтори сотні команд. У щоденній роботі автоматизатора живих із них приблизно вісім, і ще десяток — «коли щось пішло не так». Решту можна чесно гуглити все життя, і це нікого не бентежить.

Git has more than a hundred and fifty commands. In an automation engineer's daily work about eight of them are alive, plus another ten for "something went wrong". The rest you can honestly google for the rest of your career and nobody minds.

Вісім, які ти набираєш реально щодня:

The eight you really type every day:

  • git status — найдешевша команда в наборі. Перед кожною наступною дією.
  • git switch / git switch -c — перемкнутись на гілку або створити нову.
  • git pull — підтягнути свіжий стан гілки з віддаленого репозиторію.
  • git add — покласти зміни в staging.
  • git commit -m — зафіксувати те, що в staging.
  • git push — віддати коміти на віддалений репозиторій.
  • git diff — подивитись, що саме ти наробив.
  • git log --oneline — де ми взагалі в історії.
  • git status — the cheapest command in the set. Before every next move.
  • git switch / git switch -c — move to a branch or create a new one.
  • git pull — pull the branch's fresh state from the remote.
  • git add — put changes into staging.
  • git commit -m — record what's in staging.
  • git push — hand your commits to the remote.
  • git diff — look at what you've actually done.
  • git log --oneline — where we are in history at all.

Другий ешелон — «щось пішло не так»:

The second tier — "something went wrong":

git stash (сховати незакінчену роботу, щоб швидко перемкнутись), git restore (відкотити файл), git reset (пересунути гілку), git reflog (де я був останні дні — рятує майже завжди), git rebase, git cherry-pick, git bisect, git blame.

git stash (hide unfinished work to switch quickly), git restore (roll a file back), git reset (move a branch), git reflog (where I've been for the last few days — it saves you almost every time), git rebase, git cherry-pick, git bisect, git blame.

Одна деталь, специфічна для QA. git switch перемикає гілку, але не чіпає node_modules. Якщо в цільовій гілці інша версія Playwright у package.json, тести підуть на старих залежностях і будуть падати незрозуміло. Після переходу між гілками з різними залежностями — npm ci, це частина ритуалу, а не окремий героїзм.

One detail specific to QA. git switch changes the branch, but it doesn't touch node_modules. If the target branch has a different Playwright version in package.json, the tests will run against the old dependencies and fail for no visible reason. After moving between branches with different dependencies — npm ci. It's part of the ritual, not a separate act of heroism.

Чому switch, а не checkout: git checkout історично робить дві абсолютно різні речі — перемикає гілки й затирає файли на диску. Це той самий випадок, коли одна літера в команді відділяє «перемкнувся» від «втратив годину роботи». У 2019-му Git розділив її на switch (гілки) і restore (файли). Стара checkout нікуди не поділась і в чужих інструкціях трапляється постійно, тож знати треба обидві, а набирати — нові.

Why switch and not checkout: git checkout historically does two completely different things — it moves you between branches and it overwrites files on disk. It's the case where one letter in a command separates "I switched" from "I lost an hour of work". In 2019 Git split it into switch (branches) and restore (files). The old checkout hasn't gone anywhere and turns up in other people's instructions constantly, so you need to know both and type the new ones.

Звичайний день у гілці — від задачі до пушу
An ordinary day in a branch — from ticket to push
$ git switch main
$ git pull                       # стартуємо від свіжого main, а не від вчорашнього
$ npm ci                         # залежності під цю гілку

$ git switch -c test/128-cart-badge
#            ↑ -c = create: створити гілку і одразу перемкнутись

# ... пишемо тест, ганяємо локально ...
$ npx playwright test tests/cart.spec.ts

$ git status                     # що я взагалі наробив
$ git diff                       # читаємо власний дифф ДО коміта
$ git add tests/cart.spec.ts pages/CartPage.ts
$ git diff --staged              # і ще раз — саме те, що піде в коміт
$ git commit -m "test(cart): cover badge counter after add-to-cart"

$ git pull --rebase               # підтягнути чужі коміти під свої
$ git push -u origin test/128-cart-badge
#         ↑ -u лише перший раз: прив'язати локальну гілку до віддаленої
#           далі досить голого git push
$ git switch main
$ git pull                       # start from a fresh main, not yesterday's
$ npm ci                         # dependencies for this branch

$ git switch -c test/128-cart-badge
#            ↑ -c = create: create the branch and switch to it right away

# ... write the test, run it locally ...
$ npx playwright test tests/cart.spec.ts

$ git status                     # what have I actually changed
$ git diff                       # read your own diff BEFORE committing
$ git add tests/cart.spec.ts pages/CartPage.ts
$ git diff --staged              # and once more — exactly what goes into the commit
$ git commit -m "test(cart): cover badge counter after add-to-cart"

$ git pull --rebase               # put others' commits underneath yours
$ git push -u origin test/128-cart-badge
#         ↑ -u only the first time: link the local branch to the remote one
#           after that a plain git push is enough
Терміново перемкнутись, коли робота не дописана
Switching in a hurry when the work isn't finished
$ git stash -u                   # -u = і нові файли теж, інакше вони лишаться на диску
$ git switch hotfix/prod-login
# ... полагодили, запушили ...
$ git switch test/128-cart-badge
$ git stash pop                  # повертаємо відкладене на місце
$ git stash -u                   # -u = new files too, otherwise they stay on disk
$ git switch hotfix/prod-login
# ... fixed it, pushed it ...
$ git switch test/128-cart-badge
$ git stash pop                  # put the shelved work back
🔧 Команди й прапорці в цьому прикладі git switch -c <name> — створити гілку від поточного коміта й перемкнутись; git push -u origin <name> — -u (--set-upstream) один раз прив'язує локальну гілку до віддаленої, після чого git push і git pull працюють без аргументів; git stash -u — -u (--include-untracked) забирає в схованку ще й файли, яких Git раніше не бачив; npm ci замість npm install — ставить рівно те, що в package-lock.json, і не редагує lock-файл.
🔧 Commands and flags in this example git switch -c <name> — create a branch at the current commit and move onto it; git push -u origin <name> — -u (--set-upstream) links the local branch to the remote one once, after which git push and git pull work with no arguments; git stash -u — -u (--include-untracked) also takes files Git hasn't seen before into the stash; npm ci instead of npm install — installs exactly what's in package-lock.json and doesn't edit the lock file.
🗣 Як читати вголос У Git понад півтори сотні команд, але в щоденній роботі автоматизатора живих приблизно вісім: status, switch, pull, add, commit, push, diff і log. Другий ешелон я тримаю в голові як «щось пішло не так» — це stash, restore, reset, reflog, rebase, cherry-pick, bisect і blame, вони потрібні не щодня. Мій звичайний день виглядає так: перемкнутись на main, підтягнути свіжий стан, створити гілку через git switch -c, написати тест, прочитати власний дифф до коміта, закомітити з нормальним повідомленням і запушити. Я набираю switch і restore, а не checkout, бо стара checkout робить дві різні речі — перемикає гілки й затирає файли на диску — і саме на цьому люди втрачають роботу. І ще одна деталь, специфічна для QA: switch не переставляє node_modules, тому після переходу між гілками з різними залежностями я роблю npm ci, інакше тести підуть на старій версії Playwright і падатимуть незрозуміло.
🗣 How to say it out loud Git has more than a hundred and fifty commands, but in day-to-day automation work about eight are actually alive: status, switch, pull, add, commit, push, diff and log. The second tier I keep in my head under "something went wrong" — stash, restore, reset, reflog, rebase, cherry-pick, bisect and blame — and I don't need those every day. A normal day looks like this: switch to main, pull, create a branch with git switch -c, write the test, read my own diff before committing, commit with a sane message and push. I type switch and restore rather than checkout, because the old checkout does two different jobs — it moves branches and it overwrites files on disk — and that's exactly where people lose work. And one QA-specific detail: switch doesn't reinstall node_modules, so after moving between branches with different dependencies I run npm ci, otherwise the tests run against an old Playwright and fail in ways that make no sense.
3 .gitignore у тест-проєкті: що ігнорувати, що комітити і чому це не одне й те саме.gitignore in a test project: what to ignore, what to commit, and why those aren't the same question
ОсновиBasicsMiddle▶

Правило одне, і воно вирішує 95% питань: ігноруємо все, що згенеровано з коду, і тримаємо все, з чого код генерується. Репозиторій має бути описом того, як отримати результат, а не архівом результатів.

There's one rule, and it settles 95% of the questions: ignore everything that is generated from the code, and keep everything the code is generated from. A repository should describe how to get the result, not archive the results.

У тест-проєкті генерованого несподівано багато, і воно важке. Один прогін Playwright із трейсами й відео легко дає сотні мегабайт. Закомітьте це раз — і воно назавжди в історії репозиторію, бо історія незмінна: видалення файлу наступним комітом не прибирає його з попередніх.

In a test project there is surprisingly a lot of generated output, and it's heavy. One Playwright run with traces and video easily comes to hundreds of megabytes. Commit that once and it's in the repository's history forever, because history is immutable: deleting the file in the next commit doesn't remove it from the earlier ones.

Що ігноруємо в Playwright-проєкті:

What we ignore in a Playwright project:

  • node_modules/ — відтворюється з package-lock.json за одну команду.
  • test-results/ — трейси, відео, скріншоти падінь. Артефакт прогону.
  • playwright-report/, blob-report/, allure-results/ — звіти.
  • .env і будь-які локальні креденшели. Ігнорувати мало — про це нижче.
  • storageState.json / .auth/ — збережена сесія залогіненого користувача. Це фактично діючий токен у відкритому вигляді.
  • node_modules/ — reproduced from package-lock.json with one command.
  • test-results/ — traces, video, failure screenshots. Output of a run.
  • playwright-report/, blob-report/, allure-results/ — reports.
  • .env and any local credentials. Ignoring them isn't enough — more on that below.
  • storageState.json / .auth/ — the saved session of a logged-in user. That's effectively a working token in plain text.

Що НЕ ігноруємо, хоч воно й схоже на сміття:

What we do NOT ignore, even though it looks like junk:

Найважливіший виняток — baseline-скріншоти візуальних тестів (*-snapshots/). Це не артефакт, це еталон, з яким порівнюється прогін. Без нього візуальні тести не мають з чим порівнювати й у CI перегенерують еталон самі на себе — тест зеленіє завжди й не ловить нічого. Тобто скріншот падіння ігноруємо, а скріншот-еталон комітимо, і плутати їх не можна.

The most important exception is the baseline screenshots of visual tests (*-snapshots/). That isn't output, it's the reference a run gets compared against. Without it visual tests have nothing to compare to, and in CI they regenerate the reference from themselves — the test goes green every time and catches nothing. So a failure screenshot is ignored and a baseline screenshot is committed, and confusing the two is not an option.

Так само комітимо package-lock.json (без нього npm ci нічого не гарантує), конфіги, фікстури й тестові дані.

We commit package-lock.json the same way (without it npm ci guarantees nothing), along with configs, fixtures and test data.

Пастка, на якій ловляться всі:

The trap everyone falls into:

.gitignore діє тільки на файли, яких Git ще не відстежує. Якщо test-results/ уже потрапив у коміт, додавання рядка в .gitignore не змінить нічого — файли лишаться відстежуваними, і кожен прогін далі бруднитиме git status. Прибрати з індексу треба руками: git rm -r --cached. Ключове тут --cached — воно прибирає файл з Git, але лишає на диску.

.gitignore only applies to files Git isn't tracking yet. If test-results/ already made it into a commit, adding a line to .gitignore changes nothing — the files stay tracked, and every run after that keeps dirtying git status. You have to take them out of the index by hand: git rm -r --cached. The key part is --cached — it removes the file from Git but leaves it on disk.

Про .env окремо. Якщо секрет уже закомічений, .gitignore його не рятує: він у історії, а історія роздана всім, хто робив clone. Правильна послідовність — спершу відкликати й перевипустити ключ, і тільки потім прибирати з історії. Прибирання без ротації — це театр безпеки: ключ уже витік.

About .env specifically. If a secret is already committed, .gitignore doesn't save you: it's in history, and history has been handed to everyone who cloned. The right order is to revoke and reissue the key first, and only then clean it out of history. Cleaning without rotating is security theatre: the key has already leaked.

.gitignore для Playwright-проєкту
.gitignore for a Playwright project
# --- залежності ---
node_modules/

# --- артефакти прогону: трейси, відео, скріншоти падінь ---
test-results/
playwright-report/
blob-report/
playwright/.cache/
allure-results/
allure-report/

# --- секрети й локальний стан ---
.env
.env.*
!.env.example          # ! = виняток: шаблон без значень комітимо
.auth/
storageState.json

# --- сміття редакторів і ОС ---
.idea/
.vscode/*
!.vscode/settings.json # спільні налаштування команди лишаємо
.DS_Store

# --- НЕ ігноруємо: це еталони візуальних тестів, а не артефакти ---
# tests/**/*-snapshots/   ← навмисно закоментовано, хай лежить у репо
# --- dependencies ---
node_modules/

# --- run artifacts: traces, videos, failure screenshots ---
test-results/
playwright-report/
blob-report/
playwright/.cache/
allure-results/
allure-report/

# --- secrets and local state ---
.env
.env.*
!.env.example          # ! = exception: the template without values is committed
.auth/
storageState.json

# --- editor and OS junk ---
.idea/
.vscode/*
!.vscode/settings.json # shared team settings stay
.DS_Store

# --- NOT ignored: these are visual test baselines, not artifacts ---
# tests/**/*-snapshots/   ← commented out on purpose, keep it in the repo
Артефакти вже в репо — як прибрати, не втративши файли на диску
Artifacts are already in the repo — how to remove them without losing the files on disk
$ # .gitignore додали, а git status усе одно брудний:
$ git status --short
 M test-results/cart-spec/trace.zip
 M playwright-report/index.html

$ # бо ці файли Git уже відстежує. Прибираємо з індексу:
$ git rm -r --cached test-results playwright-report
#           ↑↑↑↑↑↑↑↑ без --cached воно видалить їх і з диска

$ git commit -m "chore: stop tracking Playwright run artifacts"
$ # з цього коміту .gitignore нарешті працює

$ # перевірити, чому конкретний файл ігнорується (або НЕ ігнорується):
$ git check-ignore -v test-results/trace.zip
.gitignore:6:test-results/    test-results/trace.zip
$ # .gitignore was added, yet git status is still dirty:
$ git status --short
 M test-results/cart-spec/trace.zip
 M playwright-report/index.html

$ # because Git already tracks these files. Remove them from the index:
$ git rm -r --cached test-results playwright-report
#           ↑↑↑↑↑↑↑↑ without --cached it deletes them from disk too

$ git commit -m "chore: stop tracking Playwright run artifacts"
$ # from this commit on .gitignore finally works

$ # check why a specific file is ignored (or NOT ignored):
$ git check-ignore -v test-results/trace.zip
.gitignore:6:test-results/    test-results/trace.zip
🔧 Команди й синтаксис у цьому прикладі Синтаксис .gitignore: dir/ — тільки каталог; *.log — шаблон; !файл — виняток із попереднього правила (працює, лише якщо батьківський каталог не виключено цілком); **/ — будь-яка глибина вкладеності. Команди: git rm -r --cached <шлях> — зняти з відстеження, лишивши на диску; git check-ignore -v <шлях> — показати, який саме рядок якого файлу спричинив ігнорування; git status --short — компактний вивід у дві колонки.
🔧 Commands and syntax in this example .gitignore syntax: dir/ — a directory only; *.log — a pattern; !file — an exception to an earlier rule (it works only if the parent directory isn't excluded wholesale); **/ — any nesting depth. Commands: git rm -r --cached <path> — untrack it while leaving it on disk; git check-ignore -v <path> — show which line of which file caused the ignore; git status --short — compact two-column output.
🗣 Як читати вголос Правило для .gitignore у мене одне: ігноруємо все, що згенеровано з коду, і тримаємо все, з чого код генерується. У тест-проєкті це node_modules, test-results із трейсами й відео, playwright-report, allure-results, а також .env і storageState — останнє це фактично живий токен залогіненої сесії. Важливий виняток, на якому люди спотикаються: baseline-скріншоти візуальних тестів комітяться обов'язково, бо це еталон, а не артефакт; без них CI перегенерує еталон сам на себе, і візуальний тест зеленітиме завжди, нічого не ловлячи. Друга пастка — .gitignore діє тільки на файли, яких Git ще не відстежує: якщо артефакти вже потрапили в коміт, треба зняти їх з індексу через git rm -r --cached, інакше рядок у .gitignore не змінить нічого. А якщо в історію потрапив секрет, то порядок дій зворотний до інтуїтивного: спершу відкликати й перевипустити ключ, і лише потім чистити історію, бо на момент виявлення він уже роздався всім, хто робив clone.
🗣 How to say it out loud My rule for .gitignore is a single line: ignore everything generated from the code, and keep everything the code is generated from. In a test project that's node_modules, test-results with the traces and video, playwright-report, allure-results, plus .env and storageState — and that last one is effectively a live session token sitting in plain text. There's one exception people trip over: baseline screenshots for visual tests get committed, always, because they're the reference and not output; without them CI regenerates the reference from itself and the visual test goes green forever while catching nothing. The second trap is that .gitignore only applies to files Git isn't tracking yet — if the artifacts are already in a commit, I have to untrack them with git rm -r --cached, otherwise the new line changes nothing. And if a secret made it into history, the order is the opposite of what feels natural: revoke and reissue the key first, clean history second, because by the time you notice it has already been handed to everyone who cloned.
4 Що таке detached HEAD, як ти в нього потрапив і як вийти без втратWhat detached HEAD is, how you got there, and how to get out without losing anything
ОсновиBasicsMiddle▶

Зазвичай HEAD вказує не на коміт, а на гілку. Файл .git/HEAD буквально містить рядок ref: refs/heads/main — «я на гілці main», а вже гілка вказує на конкретний коміт. Саме через цей подвійний перехід новий коміт автоматично зсуває гілку вперед: HEAD дивиться на гілку, гілка — на останній коміт.

Normally HEAD points not at a commit but at a branch. The file .git/HEAD literally contains the line ref: refs/heads/main — "I'm on branch main" — and the branch in turn points at a specific commit. It's that double hop that makes a new commit move the branch forward automatically: HEAD looks at the branch, the branch looks at the last commit.

Detached HEAD — це коли посередині ланцюжка немає гілки: .git/HEAD містить голий SHA. Ти перебуваєш на конкретному коміті напряму. Все працює, тести запускаються, комітити теж можна — просто нова гілка вперед не їде, бо її тут немає.

Detached HEAD is when the branch in the middle of that chain is missing: .git/HEAD holds a bare SHA. You're standing directly on a commit. Everything works, tests run, you can commit too — it's just that nothing moves forward afterwards, because there's no branch here to move.

Як ти туди потрапляєш:

How you end up there:

  • git checkout <sha> — пішов подивитись, як тест поводився два тижні тому.
  • git checkout v2.3.0 — перемкнувся на тег. Тег вказує на коміт, а не на гілку.
  • git checkout origin/main — це віддалена гілка, локально вона read-only вказівник.
  • git bisect start — увесь пошук зламаного коміта відбувається в detached HEAD, і це нормальний режим роботи цієї команди.
  • Кожен запуск GitHub Actions. actions/checkout за замовчуванням чекаутить конкретний SHA, тому в CI ти завжди в detached HEAD. Тому й падають скрипти, які роблять git push HEAD:main або читають назву поточної гілки з git branch --show-current — у detached стані вона порожня.
  • git checkout <sha> — you went to look at how a test behaved two weeks ago.
  • git checkout v2.3.0 — you moved onto a tag. A tag points at a commit, not at a branch.
  • git checkout origin/main — that's a remote branch; locally it's a read-only pointer.
  • git bisect start — the entire hunt for the broken commit happens in detached HEAD, and that's this command's normal mode of operation.
  • Every GitHub Actions run. actions/checkout checks out a specific SHA by default, so in CI you are always in detached HEAD. That's why scripts that do git push HEAD:main, or read the current branch name from git branch --show-current, fall over — in a detached state that name is empty.

Чим це справді небезпечно. Не самим станом — станом байдуже. Небезпечні коміти, зроблені в ньому. На них не вказує жодна гілка й жоден тег, тобто в звичайному git log їх не видно. Перемкнувся на іншу гілку — і зникло посилання. Дані не стерлись одразу, вони живуть у reflog і піднімаються звідти, але git gc колись прибере недосяжні об'єкти назавжди.

What is actually dangerous about it. Not the state itself — the state doesn't care. The dangerous part is the commits made in it. No branch and no tag points at them, so a plain git log never shows them. Switch to another branch and the reference is gone. The data isn't erased right away, it lives in the reflog and can be pulled back from there, but git gc will eventually collect unreachable objects for good.

Правило виходу: якщо ти нічого не комітив — просто git switch -, повернешся туди, звідки прийшов. Якщо комітив — спочатку git switch -c <ім'я> прямо тут, і аж потім перемикайся. Гілка — це і є те, що не дасть комітам загубитись.

The exit rule: if you committed nothing — just git switch - and you're back where you came from. If you did commit — first git switch -c <name> right there, and only then move away. A branch is exactly the thing that stops those commits from getting lost.

Схема: нормальний стан і detached HEAD
Diagram: the normal state and detached HEAD
НОРМАЛЬНО — HEAD дивиться на гілку, гілка на коміт

   HEAD ──▶ refs/heads/main ──▶ c3 ──▶ c2 ──▶ c1
                                ▲
   новий коміт c4 зсуне main уперед автоматично ──┘


DETACHED — HEAD дивиться прямо на коміт, гілки посередині немає

   HEAD ──────────────────────▶ c2 ──▶ c1
                                ▲
   refs/heads/main ──▶ c3 ──────┘

   коміт тут створить c2a, на який НЕ вказує жодна гілка:

   HEAD ──▶ c2a ──▶ c2 ──▶ c1
            ▲
            └── перемкнувся на main — і на c2a більше ніхто не вказує
                видно лише через git reflog, доки не прийшов git gc
NORMAL — HEAD points to a branch, the branch points to a commit

   HEAD ──▶ refs/heads/main ──▶ c3 ──▶ c2 ──▶ c1
                                ▲
   a new commit c4 moves main forward automatically ──┘


DETACHED — HEAD points straight at a commit, no branch in between

   HEAD ──────────────────────▶ c2 ──▶ c1
                                ▲
   refs/heads/main ──▶ c3 ──────┘

   a commit here creates c2a, which NO branch points to:

   HEAD ──▶ c2a ──▶ c2 ──▶ c1
            ▲
            └── switched to main — and now nothing points to c2a
                only visible via git reflog, until git gc comes along
Потрапив, попрацював, вийшов — обидва сценарії
Got there, did some work, got out — both scenarios
$ git checkout a1b2c3d
You are in 'detached HEAD' state. ← Git попереджає чесно і одразу

$ git branch --show-current
                        ← порожньо. Гілки немає. Це й ламає CI-скрипти

# --- сценарій 1: просто подивився, нічого не комітив ---
$ git switch -              # - = «туди, де я був»

# --- сценарій 2: щось наробив і закомітив ---
$ git commit -m "test: reproduce the flake on the old locator"
$ git switch -c investigate/flake-cart-badge
#      ↑ ГІЛКУ СТВОРЮЄМО ТУТ, до перемикання. Далі коміт у безпеці

# --- сценарій 3: уже перемкнувся і зрозумів, що втратив коміт ---
$ git reflog
a1b2c3d HEAD@{2}: checkout: moving from main to a1b2c3d
9f8e7d6 HEAD@{1}: commit: test: reproduce the flake  ← ось він
c3d4e5f HEAD@{0}: checkout: moving from 9f8e7d6 to main

$ git switch -c investigate/flake-cart-badge 9f8e7d6
# коміт знайдено і прив'язано до гілки — тепер він досяжний
$ git checkout a1b2c3d
You are in 'detached HEAD' state. ← Git warns you honestly and immediately

$ git branch --show-current
                        ← empty. No branch. This is what breaks CI scripts

# --- scenario 1: just looked around, committed nothing ---
$ git switch -              # - = "back to where I was"

# --- scenario 2: changed something and committed it ---
$ git commit -m "test: reproduce the flake on the old locator"
$ git switch -c investigate/flake-cart-badge
#      ↑ CREATE THE BRANCH HERE, before switching. Then the commit is safe

# --- scenario 3: already switched and realised the commit is lost ---
$ git reflog
a1b2c3d HEAD@{2}: checkout: moving from main to a1b2c3d
9f8e7d6 HEAD@{1}: commit: test: reproduce the flake  ← there it is
c3d4e5f HEAD@{0}: checkout: moving from 9f8e7d6 to main

$ git switch -c investigate/flake-cart-badge 9f8e7d6
# commit found and attached to a branch — now it is reachable
🔧 Команди й прапорці в цьому прикладі git switch - — дефіс як скорочення «попередня гілка», той самий синтаксис, що й cd - у шелі; git switch -c <ім'я> [<sha>] — створити гілку на вказаному коміті, а без SHA — на поточному; git branch --show-current — назва поточної гілки, у detached стані порожня (у CI-скриптах це перевіряють, а не припускають); git reflog — локальний журнал усіх переміщень HEAD, включно з тими, куди вже нічого не вказує.
🔧 Commands and flags in this example git switch - — the dash is shorthand for "the previous branch", the same syntax as cd - in a shell; git switch -c <name> [<sha>] — create a branch at the given commit, or at the current one if no SHA is given; git branch --show-current — the current branch name, empty in a detached state (CI scripts check this rather than assume it); git reflog — the local journal of every HEAD move, including the ones nothing points at any more.
🗣 Як читати вголос Зазвичай HEAD вказує не на коміт, а на гілку — у файлі .git/HEAD лежить рядок ref refs/heads/main — і вже гілка вказує на коміт. Саме через цей подвійний перехід новий коміт автоматично зсуває гілку вперед. Detached HEAD — це коли посередині гілки немає і HEAD містить голий SHA, тобто я стою прямо на коміті. Потрапляю я туди щоразу, коли роблю checkout на SHA або на тег, коли запускаю git bisect, і — що важливо — у кожному запуску GitHub Actions, бо actions/checkout за замовчуванням чекаутить конкретний SHA. Саме тому в CI падають скрипти, які читають назву поточної гілки: в detached стані вона порожня. Небезпечний тут не сам стан, а коміти, зроблені в ньому: на них не вказує жодна гілка, тож після перемикання вони видимі лише через reflog і колись їх приберe git gc. Тому правило просте: якщо я нічого не комітив — git switch дефіс і все; якщо комітив — спершу створюю гілку прямо тут через git switch -c, і аж потім перемикаюсь.
🗣 How to say it out loud Normally HEAD doesn't point at a commit, it points at a branch — .git/HEAD holds the line ref refs heads main — and the branch points at the commit. That double hop is why a new commit moves the branch forward on its own. Detached HEAD is when the branch in the middle is missing and HEAD holds a bare SHA, so I'm standing directly on a commit. I get there whenever I check out a SHA or a tag, whenever I run git bisect, and — this is the one that actually matters — on every GitHub Actions run, because actions/checkout checks out a specific SHA by default. That's exactly why CI scripts that read the current branch name break: in a detached state that name is empty. The state itself is harmless; what's dangerous is the commits I make in it, because nothing points at them, so once I switch away they're only reachable through the reflog and git gc will collect them eventually. So the rule is simple: if I committed nothing, git switch dash and done; if I did commit, I create a branch right there with git switch -c first, and switch away second.
5 blob, tree, commit, tag — чотири об'єкти, з яких випливає решта Gitblob, tree, commit, tag — the four objects the rest of Git follows from
ОсновиBasicsSenior▶

Каталог .git/objects — це звичайне key-value сховище. Ключ — хеш від вмісту, значення — сам вміст. Ніякої бази даних, ніяких таблиць. Типів значень рівно чотири.

The .git/objects directory is an ordinary key-value store. The key is a hash of the content, the value is the content itself. No database, no tables. There are exactly four kinds of value.

blob

blob

Вміст файлу і більше нічого. Без імені, без шляху, без прав доступу. Два однакові файли в різних папках — це один blob, бо однаковий вміст дає однаковий хеш. Перейменування файлу не створює нового blob взагалі: змінюється лише запис у дереві.

The contents of a file and nothing else. No name, no path, no permissions. Two identical files in different folders are one blob, because identical content gives an identical hash. Renaming a file doesn't create a new blob at all — only the entry in the tree changes.

tree

tree

Каталог. Список рядків «режим + тип + хеш + ім'я». Саме тут живуть імена файлів і структура папок. Підпапка — це запис типу tree всередині tree, тобто дерево буквально.

A directory. A list of lines: "mode + type + hash + name". This is where file names and folder structure actually live. A subfolder is a tree entry inside a tree — a tree, literally.

commit

commit

Знімок. Містить: хеш кореневого tree, список батьківських комітів (нуль для першого, один для звичайного, два і більше для merge), автора з датою, коммітера з датою і повідомлення. Коміт не зберігає дифф. Дифф — це те, що Git обчислює на льоту, коли ти просиш git diff чи git show, порівнюючи два дерева.

A snapshot. It holds the hash of the root tree, the list of parent commits (none for the first, one for an ordinary commit, two or more for a merge), the author with a date, the committer with a date, and the message. A commit doesn't store a diff. The diff is what Git computes on the fly when you ask for git diff or git show, by comparing two trees.

tag

tag

Тільки анотований тег (git tag -a) є повноцінним об'єктом: вказівник на коміт плюс автор, дата, повідомлення й опційно GPG-підпис. Легкий тег (git tag v1.0) об'єктом не є — це просто файл у refs/tags з хешем усередині. Для версії тест-сьюта, на яку хтось посилатиметься через півроку, потрібен анотований: у нього є автор і дата.

Only an annotated tag (git tag -a) is a real object: a pointer to a commit plus an author, a date, a message and optionally a GPG signature. A lightweight tag (git tag v1.0) isn't an object — it's just a file under refs/tags with a hash inside. For a version of a test suite someone will refer back to in six months you want the annotated one: it has an author and a date.

І ось чому це варте окремої картки — з цих чотирьох об'єктів випливає майже все:

And here's why this deserves a card of its own — almost everything follows from those four objects:

  • Гілка не є об'єктом. Це файл на 41 байт із хешем усередині. Тому створення гілки миттєве й безкоштовне незалежно від розміру репозиторію, і тому branching у Git дешевий, а в старих VCS був подією.
  • Коміт незмінний. Хеш рахується від вмісту, тож змінити хоч кому в повідомленні — це створити інший об'єкт. amend, rebase й cherry-pick не редагують коміти, вони роблять нові.
  • Знімки не роздувають репозиторій. Коміт посилається на повне дерево, але незмінені файли перевикористовують ті самі blob'и. Тисяча комітів у проєкті з одним зміненим тестом — це тисяча дешевих дерев, а не тисяча копій.
  • git status швидкий саме тому, що порівнює хеші дерев, а не байти файлів: збіглися хеші — цілу гілку файлової системи можна не читати.
  • Історія самоперевірна. Батьківський коміт входить у вміст дочірнього, отже у його хеш. Підмінити коміт посередині історії, не змінивши всі наступні хеші, неможливо.
  • A branch isn't an object. It's a 41-byte file with a hash in it. That's why creating a branch is instant and free no matter how big the repository is, and why branching in Git is cheap where in older VCS it was an event.
  • A commit is immutable. The hash is computed from the content, so changing even a comma in the message means creating a different object. amend, rebase and cherry-pick don't edit commits, they make new ones.
  • Snapshots don't bloat the repository. A commit references a full tree, but unchanged files reuse the same blobs. A thousand commits in a project with one changed test is a thousand cheap trees, not a thousand copies.
  • git status is fast precisely because it compares tree hashes rather than file bytes: if the hashes match, a whole branch of the filesystem never has to be read.
  • History verifies itself. The parent commit is part of the child's content, and therefore part of its hash. Swapping out a commit in the middle of history without changing every hash after it is impossible.

Чому це питання взагалі ставлять на співбесіді: не заради cat-file. Людина, яка тримає в голові цю модель, не питає «а куди подівся мій коміт після rebase» і не боїться force-push — бо розуміє, що старий об'єкт нікуди не зник, просто на нього більше не вказує гілка.

Why this comes up in interviews at all: not for the sake of cat-file. Someone who carries this model in their head doesn't ask "where did my commit go after the rebase" and isn't afraid of a force push — because they understand that the old object hasn't gone anywhere, there's just no branch pointing at it any more.

Схема: як один коміт розкладається на об'єкти
Diagram: how one commit breaks down into objects
commit a1b2c3d
  ├── tree     9f8e7d6              ← корінь проєкту
  ├── parent   0d0c0b0              ← попередній коміт (входить у хеш!)
  ├── author   Olena  2026-09-10
  └── message  "test(cart): cover badge counter"

tree 9f8e7d6                        ← корінь
  100644 blob 5a5b5c5   playwright.config.ts
  100644 blob 7c7d7e7   package.json
  040000 tree 3e3f3a3   tests          ← підпапка = tree в tree

tree 3e3f3a3                        ← tests/
  100644 blob 1a1b1c1   cart.spec.ts
  100644 blob 2a2b2c2   login.spec.ts

blob 1a1b1c1                        ← самі байти файлу.
                                 Без імені. Без шляху. Без прав.
                                 Ім'я живе в tree, а не тут.
commit a1b2c3d
  ├── tree     9f8e7d6              ← project root
  ├── parent   0d0c0b0              ← previous commit (part of the hash!)
  ├── author   Olena  2026-09-10
  └── message  "test(cart): cover badge counter"

tree 9f8e7d6                        ← root
  100644 blob 5a5b5c5   playwright.config.ts
  100644 blob 7c7d7e7   package.json
  040000 tree 3e3f3a3   tests          ← subfolder = tree inside a tree

tree 3e3f3a3                        ← tests/
  100644 blob 1a1b1c1   cart.spec.ts
  100644 blob 2a2b2c2   login.spec.ts

blob 1a1b1c1                        ← just the file bytes.
                                 No name. No path. No permissions.
                                 The name lives in the tree, not here.
Розібрати власний коміт руками — три команди
Take your own commit apart by hand — three commands
$ git cat-file -t HEAD          # -t = тип об'єкта
commit

$ git cat-file -p HEAD          # -p = pretty-print вмісту
tree 9f8e7d6
parent 0d0c0b0
author Olena <olena@example.com> 1757462400 +0300
committer Olena <olena@example.com> 1757462400 +0300

test(cart): cover badge counter

$ git cat-file -p 9f8e7d6       # дивимось у кореневе дерево
100644 blob 5a5b5c5    playwright.config.ts
040000 tree 3e3f3a3    tests

$ # доказ, що blob не знає свого імені:
$ git cat-file -p 1a1b1c1 | head -1
import { test, expect } from '@playwright/test';

$ # і що однаковий вміст = один об'єкт:
$ echo "hello" | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ echo "hello" | git hash-object --stdin   # той самий хеш, завжди
ce013625030ba8dba906f756967f9e9ca394464a
$ git cat-file -t HEAD          # -t = object type
commit

$ git cat-file -p HEAD          # -p = pretty-print the content
tree 9f8e7d6
parent 0d0c0b0
author Olena <olena@example.com> 1757462400 +0300
committer Olena <olena@example.com> 1757462400 +0300

test(cart): cover badge counter

$ git cat-file -p 9f8e7d6       # look into the root tree
100644 blob 5a5b5c5    playwright.config.ts
040000 tree 3e3f3a3    tests

$ # proof that a blob does not know its own name:
$ git cat-file -p 1a1b1c1 | head -1
import { test, expect } from '@playwright/test';

$ # and that identical content = one object:
$ echo "hello" | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ echo "hello" | git hash-object --stdin   # the same hash, always
ce013625030ba8dba906f756967f9e9ca394464a
🔧 Команди й прапорці в цьому прикладі git cat-file -t <об'єкт> — тип об'єкта; -p — читабельний вміст; git hash-object --stdin — порахувати хеш, нічого не записуючи в репозиторій; git tag -a v1.0 -m "..." — анотований тег, повноцінний об'єкт із автором і датою, на відміну від git tag v1.0; git rev-parse HEAD — розгорнути будь-яке посилання до повного SHA. Числа 100644 і 040000 у дереві — це режими у стилі Unix: звичайний файл і каталог.
🔧 Commands and flags in this example git cat-file -t <object> — the object's type; -p — its readable content; git hash-object --stdin — compute a hash without writing anything into the repository; git tag -a v1.0 -m "..." — an annotated tag, a real object with an author and a date, unlike git tag v1.0; git rev-parse HEAD — expand any reference into a full SHA. The numbers 100644 and 040000 in a tree are Unix-style modes: a regular file and a directory.
🗣 Як читати вголос Git усередині — це key-value сховище, де ключ це хеш від вмісту, а значень усього чотири типи. Blob — це вміст файлу без імені й шляху; ім'я живе в tree, тобто в об'єкті-каталозі, який перелічує записи «режим, хеш, ім'я». Commit — це знімок: хеш кореневого дерева, посилання на батьків, автор із датою і повідомлення; дуже важливо, що коміт не зберігає дифф — дифф Git рахує на льоту, порівнюючи два дерева. Четвертий об'єкт — анотований тег, у якого є автор, дата й опційно підпис, тоді як легкий тег взагалі не об'єкт, а просто файл з хешем. І з цієї моделі напряму випливає майже все інше: гілка не об'єкт, а файл на сорок один байт, тому створення гілки безкоштовне; коміт незмінний, бо хеш рахується від вмісту, тому amend, rebase і cherry-pick не редагують коміти, а створюють нові; репозиторій не роздувається, бо незмінені файли перевикористовують ті самі blob-и; а git status швидкий, бо порівнює хеші дерев, а не байти файлів. Питання про об'єктну модель ставлять не заради cat-file — людина, яка її тримає в голові, не питає, куди подівся коміт після rebase.
🗣 How to say it out loud Underneath, Git is a key-value store where the key is a hash of the content, and there are only four kinds of value. A blob is the contents of a file with no name and no path; the name lives in a tree, the directory object that lists entries as mode, hash and name. A commit is a snapshot — the hash of the root tree, the parent links, the author with a date and the message — and the important part is that a commit doesn't store a diff; Git computes the diff on the fly by comparing two trees. The fourth object is the annotated tag, which has an author, a date and optionally a signature, whereas a lightweight tag isn't an object at all, just a file with a hash in it. Almost everything else falls straight out of that model: a branch isn't an object but a forty-one-byte file, which is why branching costs nothing; a commit is immutable because the hash comes from the content, which is why amend, rebase and cherry-pick create new commits instead of editing old ones; the repo doesn't bloat because unchanged files reuse the same blobs; and git status is fast because it compares tree hashes rather than file bytes. People don't ask this for the sake of cat-file — someone who holds the model doesn't wonder where a commit went after a rebase, and isn't scared of a force push.
6 Навіщо взагалі staging area, якщо є git commit -amWhy have a staging area at all, when git commit -am exists
ОсновиBasicsSenior▶

Чесна відповідь, яку рідко чують на співбесіді: у більшості випадків git commit -am нормальний, і сперечатися з цим — марна суперечка. Цікаве питання не «чи можна», а «які саме випадки кусаються, і чому в тест-репозиторії їх помітно більше, ніж у продуктовому».

The honest answer, which interviewers rarely hear: in most cases git commit -am is fine, and arguing about it is a waste of everyone's time. The interesting question isn't "is it allowed", it's "which cases bite, and why a test repository has noticeably more of them than a product one".

Staging area — це не бюрократія між тобою й комітом. Це місце, де коміт збирається по шматках, і де ти востаннє читаєш власний дифф перед тим, як його прочитає колега. -a прибирає обидві ці можливості разом.

The staging area isn't bureaucracy standing between you and a commit. It's where a commit is assembled piece by piece, and where you read your own diff one last time before a colleague reads it. -a takes both of those away at once.

Три речі, які -a регулярно затягує в коміт у QA-репозиторії:

Three things -a regularly drags into a commit in a QA repository:

  • test.only чи describe.only, який ти поставив, щоб ганяти один тест. У CI прогін зеленіє за десять секунд, бо решта сьюта просто не запустилась. Ніхто не помітить тижнями.
  • Локальні правки конфіга: baseURL на localhost, headless: false, timeout: 0, workers: 1. Кожна з них ламає CI по-своєму й ні одна не виглядає як помилка в дифі на три файли.
  • Дебажні залишки — page.pause(), зайвий console.log, закоментований expect, який ти вимкнув, щоб дійти до наступного кроку. Останнє найгірше: тест лишається зеленим і нічого не перевіряє.
  • test.only or describe.only that you added to run a single test. In CI the run goes green in ten seconds, because the rest of the suite simply never started. Nobody notices for weeks.
  • Local config edits: baseURL pointed at localhost, headless: false, timeout: 0, workers: 1. Each of them breaks CI in its own way, and none of them looks like a mistake in a three-file diff.
  • Debug leftovers — page.pause(), a stray console.log, a commented-out expect you switched off to get to the next step. That last one is the worst: the test stays green and checks nothing.

Окрема пастка, суто механічна: -a не додає файлів, яких Git ще не бачив. Створив новий page object, зробив commit -am — локально все зелене, бо файл на диску є, а в коміт він не потрапив. У CI падає імпорт, і виглядає це як загадковий збій оточення. Половина «а в мене локально працює» — саме звідси.

A separate, purely mechanical trap: -a doesn't add files Git hasn't seen yet. You create a new page object, run commit -am — locally everything is green, because the file is on disk, but it never got into the commit. In CI the import fails, and it reads like a mysterious environment glitch. Half of all "but it works on my machine" comes from exactly here.

Що дає staging натомість. Головний інструмент — git add -p: Git показує твій дифф хунками й на кожному питає «беремо?». Це не про Git, це про рев'ю самого себе до того, як його зробить хтось інший — і рев'ю на цьому етапі коштує секунди замість раунду коментарів у PR. Другий сценарій — розділити накопичену роботу на два осмислені коміти: окремо page object, окремо тест, який ним користується. Історія після цього читається, а git bisect по ній дає осмислені відповіді.

What staging gives you instead. The main tool is git add -p: Git walks your diff hunk by hunk and asks "taking this one?" each time. It isn't about Git, it's about reviewing yourself before someone else does — and a review at this stage costs seconds instead of a round of comments in a PR. The second use is splitting accumulated work into two meaningful commits: the page object separately, the test that uses it separately. History reads properly after that, and git bisect over it gives answers that mean something.

Де я особисто проводжу межу: -am — коли правка одна, в одному файлі, і я щойно бачив git diff. add -p — коли робота йшла більше години, зачепила конфіг, або коли я хоч раз за сесію запускав тест локально з .only. Це не питання дисципліни, це питання ціни помилки: у тест-репозиторії зайвий рядок не ламає прод, він тихо вимикає перевірки, і саме тому його ніхто не помічає.

Where I personally draw the line: -am when it's one edit, in one file, and I've just looked at git diff. add -p when the work ran longer than an hour, touched the config, or when I ran a test locally with .only at any point in the session. It isn't a question of discipline, it's a question of the cost of a mistake: in a test repository a stray line doesn't break production, it quietly switches checks off — which is exactly why nobody notices it.

git add -p — самостійне рев'ю дифа до коміта
git add -p — reviewing your own diff before the commit
$ git add -p playwright.config.ts

diff --git a/playwright.config.ts b/playwright.config.ts
@@ -12,7 +12,7 @@ export default defineConfig({
   use: {
-    baseURL: process.env.BASE_URL,
+    baseURL: 'http://localhost:3000',     ← локальна правка, у коміт не треба
   },
(1/2) Stage this hunk [y,n,q,a,d,s,e,?]? n

@@ -28,6 +28,7 @@ export default defineConfig({
   reporter: [
     ['html'],
+    ['blob'],                            ← а оце справжня зміна, беремо
   ],
(2/2) Stage this hunk [y,n,q,a,d,s,e,?]? y

$ git commit -m "chore(ci): add blob reporter for sharded runs"
$ # localhost лишився на диску — працюю далі, у репо його немає
$ git add -p playwright.config.ts

diff --git a/playwright.config.ts b/playwright.config.ts
@@ -12,7 +12,7 @@ export default defineConfig({
   use: {
-    baseURL: process.env.BASE_URL,
+    baseURL: 'http://localhost:3000',     ← local tweak, keep it out of the commit
   },
(1/2) Stage this hunk [y,n,q,a,d,s,e,?]? n

@@ -28,6 +28,7 @@ export default defineConfig({
   reporter: [
     ['html'],
+    ['blob'],                            ← and this is the real change, take it
   ],
(2/2) Stage this hunk [y,n,q,a,d,s,e,?]? y

$ git commit -m "chore(ci): add blob reporter for sharded runs"
$ # localhost stayed on disk — I keep working, the repo does not have it
Два способи не закомітити .only — дешевий і надійний
Two ways not to commit .only — the cheap one and the reliable one
$ # дешевий: подивитись перед комітом
$ git diff --staged | grep -nE "\.only|page\.pause|headless: false"
+  test.only('cart badge updates', async ({ page }) => {

$ # надійний: хай про це дбає CI, а не пам'ять людини
$ cat playwright.config.ts
export default defineConfig({
  forbidOnly: !!process.env.CI,   // у CI .only = помилка, а не зелений прогін
});
$ # cheap: look before committing
$ git diff --staged | grep -nE "\.only|page\.pause|headless: false"
+  test.only('cart badge updates', async ({ page }) => {

$ # reliable: let CI take care of it, not human memory
$ cat playwright.config.ts
export default defineConfig({
  forbidOnly: !!process.env.CI,   // in CI .only = an error, not a green run
});
🔧 Команди й прапорці в цьому прикладі git add -p (--patch) — інтерактивний вибір хунків: y взяти, n пропустити, s розбити хунк дрібніше, e відредагувати руками, q вийти; git commit -a — застейджити всі відстежувані змінені файли, нові файли не додає; git diff --staged — дифф того, що піде в коміт. З боку Playwright: forbidOnly у конфізі перетворює забутий .only на падіння прогону — це та сама перевірка, тільки автоматична.
🔧 Commands and flags in this example git add -p (--patch) — pick hunks interactively: y take it, n skip it, s split the hunk smaller, e edit it by hand, q quit; git commit -a — stage every tracked modified file, it doesn't add new ones; git diff --staged — the diff of what's going into the commit. On the Playwright side: forbidOnly in the config turns a forgotten .only into a failed run — the same check, only automatic.
🗣 Як читати вголос Чесно кажучи, у більшості випадків commit -am цілком нормальний, і сперечатися з цим не варто. Питання в тому, які випадки кусаються, і в тест-репозиторії їх помітно більше, ніж у продуктовому. Staging для мене — це місце, де коміт збирається по шматках і де я востаннє читаю власний дифф, а прапорець -a прибирає обидві ці можливості. Три речі, які він регулярно затягує в коміт: забутий test.only, через який CI зеленіє за десять секунд, локальні правки конфіга типу baseURL на localhost або headless false, і дебажні залишки на кшталт page.pause чи закоментованого expect — останнє найгірше, бо тест лишається зеленим і при цьому нічого не перевіряє. Ще одна суто механічна пастка: -a не додає нових файлів, тому створений page object просто не потрапляє в коміт, локально все працює, а в CI падає імпорт. Тому моя межа така: -am для однієї дрібної правки, яку я щойно бачив у git diff, і git add -p, коли робота тривала довше години або чіпала конфіг. І паралельно я вмикаю forbidOnly у конфізі — щоб про забутий .only дбав CI, а не моя пам'ять.
🗣 How to say it out loud Honestly, in most cases commit -am is perfectly fine, and it isn't worth arguing about. The interesting part is which cases bite, and a test repo has noticeably more of them than a product one. For me staging is where the commit gets assembled piece by piece and where I read my own diff one last time, and -a takes both of those away. Three things it regularly drags in: a forgotten test.only, which makes CI go green in ten seconds because the rest of the suite never ran; local config edits like baseURL on localhost or headless false; and debug leftovers like page.pause or a commented-out expect — that last one is the worst, because the test stays green and checks nothing. There's also a purely mechanical trap: -a doesn't add new files, so a page object I've just created never lands in the commit, everything works locally, and CI fails on the import. So my line is -am for one small edit I've just seen in git diff, and git add -p when the work ran over an hour or touched the config. In parallel I turn forbidOnly on in the config, so a forgotten .only is CI's problem rather than my memory's.
7 git reset --soft, --mixed, --hard — три дерева і єдина команда, що знищує роботуgit reset --soft, --mixed, --hard — three trees, and the one command that destroys work
ОсновиBasicsSenior▶

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

This is the same trio from the first card — HEAD, staging, the working directory — except now you can see why it was worth separating them. git reset does one thing: it moves the branch HEAD is looking at onto a different commit. The flag only decides how far it drags the other two trees along behind it.

--soft — рухає тільки HEAD

--soft — moves HEAD only

Staging і диск лишаються як були. Всі зміни зі скасованих комітів опиняються застейдженими й готовими до нового коміта. Це робоча конячка для «злити три брудні коміти в один нормальний перед PR»: reset --soft HEAD~3, потім один commit.

Staging and the disk stay as they were. Every change from the undone commits ends up staged and ready for a new commit. This is the workhorse for "squash three messy commits into one decent one before the PR": reset --soft HEAD~3, then a single commit.

--mixed — HEAD і staging (це поведінка за замовчуванням)

--mixed — HEAD and staging (this is the default)

Диск не чіпає. Зміни лишаються на місці, але вже не застейджені. Голий git reset без аргументів — це саме --mixed HEAD, тобто «розстейдж усе», найбезпечніша й найчастіша форма.

It doesn't touch the disk. The changes stay where they are, they're just no longer staged. Bare git reset with no arguments is exactly --mixed HEAD, i.e. "unstage everything" — the safest and most common form.

--hard — HEAD, staging і робоча директорія

--hard — HEAD, staging and the working directory

Тут і криється єдина справді небезпечна операція в Git. Скасовані коміти нікуди не діваються: вони лишаються об'єктами в .git/objects і піднімаються з git reflog. А от незакомічені зміни зникають безслідно — і не тому, що Git їх видалив, а тому, що їх ніколи не існувало як об'єкта. Reflog журналює переміщення HEAD, він нічого не знає про твій диск.

And here is the one genuinely dangerous operation in Git. The undone commits don't go anywhere: they stay as objects in .git/objects and come back out of git reflog. But uncommitted changes disappear without a trace — not because Git deleted them, but because they never existed as an object. The reflog journals HEAD moves; it knows nothing about your disk.

Практичне з цього. Різниця між «страшна команда» і «нормальний інструмент» тут проходить рівно по межі закоміченого. Якщо робота в коміті — --hard оборотний одним рядком із reflog. Якщо ні — незворотний. Тому перед --hard я роблю git stash -u: це коштує секунду й перетворює операцію на оборотну, навіть якщо я помилився з номером коміта.

What follows in practice. The line between "a scary command" and "a normal tool" runs exactly along what has been committed. If the work is in a commit, --hard is reversible with one line from the reflog. If it isn't, it's irreversible. That's why before --hard I run git stash -u: it costs a second and makes the operation reversible even if I got the commit number wrong.

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

And the main limitation, which is social rather than technical. reset moves a branch into the past. On your own local branch that concerns nobody. On a branch that's already pushed and that somebody has already pulled, it means you and your colleague now hold different ideas about where that branch is. To undo a commit that's already published there is git revert: it doesn't rewrite history, it adds a new commit applying the opposite changes. The rule is short: yours and local — reset; shared and pushed — revert.

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

In an interview, "what's the difference between reset and revert" isn't testing your knowledge of flags. It's testing whether you understand that history comes in a private form and a published one, and that those are different modes of working towards a result that looks the same.

Що саме зачіпає кожен прапорець
Exactly what each flag touches
                     HEAD/гілка   staging      робоча директорія
  ─────────────────────────────────────────────────────────────────
  --soft                рухає         —                 —
  --mixed  (default)    рухає       скидає              —
  --hard                рухає       скидає         ЗАТИРАЄ
  ─────────────────────────────────────────────────────────────────

  Скасовані КОМІТИ живі в усіх трьох випадках → git reflog їх поверне.
  НЕЗАКОМІЧЕНІ зміни при --hard зникають назавжди: вони ніколи не були
  об'єктом, а reflog журналює тільки переміщення HEAD.
                     HEAD/branch  staging      working directory
  ─────────────────────────────────────────────────────────────────
  --soft                moves         —                 —
  --mixed  (default)    moves       resets              —
  --hard                moves       resets         WIPES
  ─────────────────────────────────────────────────────────────────

  Undone COMMITS survive in all three cases → git reflog brings them back.
  UNCOMMITTED changes are gone for good with --hard: they were never
  an object, and reflog only records HEAD movements.
Найчастіший корисний сценарій: злити три брудні коміти в один
The most common useful case: squashing three messy commits into one
$ git log --oneline -4
9f8e7d6 wip
3e3f3a3 fix typo
5a5b5c5 wip again
0d0c0b0 test(cart): add page object     ← сюди хочемо повернути гілку

$ git reset --soft 0d0c0b0
$ git status --short
M  tests/cart.spec.ts                ← усе вже в staging, диск не змінився
M  pages/CartPage.ts

$ git commit -m "test(cart): cover badge counter after add-to-cart"
# три wip-коміти замінені одним читабельним. Історія переписана —
# робимо це ДО пушу, а не після
$ git log --oneline -4
9f8e7d6 wip
3e3f3a3 fix typo
5a5b5c5 wip again
0d0c0b0 test(cart): add page object     ← this is where we want the branch back

$ git reset --soft 0d0c0b0
$ git status --short
M  tests/cart.spec.ts                ← everything is already staged, disk unchanged
M  pages/CartPage.ts

$ git commit -m "test(cart): cover badge counter after add-to-cart"
# three wip commits replaced with one readable commit. History is rewritten —
# do it BEFORE pushing, not after
--hard: як зробити його оборотним і як рятуватись, якщо не зробив
--hard: how to make it reversible, and how to recover if you didn't
$ # оборотно: секунда роботи, і незакомічене в безпеці
$ git stash -u
$ git reset --hard origin/main
$ git stash pop                 # передумав — повернув

$ # не зробив, і зніс закомічену роботу — reflog рятує:
$ git reflog
c3d4e5f HEAD@{0}: reset: moving to origin/main
9f8e7d6 HEAD@{1}: commit: test(cart): cover badge   ← ось він, живий

$ git reset --hard 9f8e7d6
$ # а от якби ті зміни не були в коміті — тут би нічого не було

$ # коміт уже в спільній гілці? Не reset, а revert:
$ git revert 9f8e7d6
# створює НОВИЙ коміт зі зворотними змінами. Історія не переписана,
# нічий pull не зламано
$ # reversible: one second of work, and uncommitted changes are safe
$ git stash -u
$ git reset --hard origin/main
$ git stash pop                 # changed my mind — brought it back

$ # didn't do that and wiped committed work — reflog to the rescue:
$ git reflog
c3d4e5f HEAD@{0}: reset: moving to origin/main
9f8e7d6 HEAD@{1}: commit: test(cart): cover badge   ← there it is, alive

$ git reset --hard 9f8e7d6
$ # but if those changes had never been committed, there would be nothing here

$ # commit already on a shared branch? Not reset, but revert:
$ git revert 9f8e7d6
# creates a NEW commit with the inverse changes. History is not rewritten,
# nobody's pull gets broken
🔧 Команди й прапорці в цьому прикладі HEAD~3 — «на три коміти назад по першому батьку» (HEAD^ — інший синтаксис, він вибирає батька в merge-коміті); git reset без прапорця = --mixed; git reset --hard origin/main — привести локальну гілку рівно до віддаленої; git stash -u — сховати зміни разом із новими файлами; git revert <sha> — новий коміт зі зворотним діффом, безпечно для опублікованої історії; git status --short — дві колонки, ліва це staging, права робоча директорія.
🔧 Commands and flags in this example HEAD~3 — "three commits back along the first parent" (HEAD^ is different syntax: it picks a parent inside a merge commit); git reset with no flag = --mixed; git reset --hard origin/main — bring the local branch exactly onto the remote one; git stash -u — stash the changes together with new files; git revert <sha> — a new commit with the opposite diff, safe for published history; git status --short — two columns, the left one staging, the right one the working directory.
🗣 Як читати вголос git reset робить одну річ — пересуває гілку, на яку дивиться HEAD, на інший коміт, а прапорець вирішує лише, наскільки далеко за собою він тягне staging і робочу директорію. Soft рухає тільки HEAD, тому всі зміни зі скасованих комітів лишаються застейдженими — це моя робоча конячка для того, щоб злити три wip-коміти в один читабельний перед PR. Mixed, він же режим за замовчуванням, ще й розстейджує, але диск не чіпає; голий git reset — це саме він. Hard додає до цього затирання робочої директорії, і ось тут єдина справді небезпечна операція в Git. Причому небезпечна вона асиметрично: скасовані коміти нікуди не діваються і піднімаються з reflog, а незакомічені зміни зникають назавжди, бо вони ніколи не існували як об'єкт, а reflog журналює переміщення HEAD, а не стан диска. Тому перед hard я роблю git stash -u — секунда роботи робить операцію оборотною. І окремо соціальне обмеження: reset переставляє гілку в минуле, тому на своїй локальній гілці це нормально, а на вже запушеній спільній треба git revert, який не переписує історію, а додає коміт зі зворотними змінами.
🗣 How to say it out loud git reset does exactly one thing — it moves the branch HEAD is looking at onto a different commit — and the flag only decides how far it drags staging and the working directory along with it. Soft moves HEAD alone, so everything from the undone commits stays staged, and that's my workhorse for squashing three wip commits into one readable one before a PR. Mixed, which is the default, also unstages but leaves the disk alone; a bare git reset is exactly that. Hard adds wiping the working directory on top, and that's the one genuinely dangerous operation in Git. It's dangerous asymmetrically, though: the undone commits are still there and come back out of the reflog, but uncommitted changes are gone for good, because they never existed as an object and the reflog journals HEAD moves, not the state of my disk. So before a hard reset I run git stash -u — a second of work makes the whole thing reversible. And there's a social limit on top of that: reset moves a branch backwards, which is fine on my own local branch, but on a shared branch that's already pushed I use git revert instead, because it adds a commit with the opposite changes rather than rewriting history.
8 Чому SHA коміта міняється від будь-якої дрібниці — і що з цього випливає на практиціWhy a commit's SHA changes over the smallest thing — and what follows from that in practice
ОсновиBasicsSenior▶

Ідентифікатор коміта — це не порядковий номер і не випадковий рядок. Це хеш від вмісту самого об'єкта. А у вміст коміта входить більше, ніж здається: кореневе дерево, список батьків, автор із датою, коммітер із власною датою і повідомлення.

A commit's identifier isn't a sequence number and isn't a random string. It's a hash of the object's own content. And a commit's content includes more than it looks like: the root tree, the list of parents, the author with a date, the committer with a date of their own, and the message.

Звідси одразу два неочевидні наслідки:

Two non-obvious consequences follow immediately:

Перший — git commit --amend без жодних змін, із тим самим текстом і тим самим кодом, дає інший SHA. Змінилася дата коммітера, а вона всередині хешу. «Той самий коміт» перестав бути тим самим.

The first — git commit --amend with no changes at all, the same message and the same code, gives a different SHA. The committer date changed, and that date is inside the hash. "The same commit" has stopped being the same commit.

Другий, важливіший — батько входить у вміст. Виправив коміт у середині гілки на п'ять комітів — і всі чотири наступні теж отримали нові SHA, хоч їх ніхто не чіпав. Історія в Git — це ланцюг, спаяний хешами, і підмінити ланку посередині непомітно неможливо. Саме на цьому тримається твердження «Git не втрачає й не підмінює дані»: не на добрій волі, а на арифметиці.

The second, and the more important one — the parent is part of the content. Fix a commit in the middle of a five-commit branch and all four after it get new SHAs as well, even though nobody touched them. History in Git is a chain welded together by hashes, and swapping a link in the middle unnoticed is impossible. That's what "Git doesn't lose or substitute data" actually rests on: not goodwill, but arithmetic.

Чотири речі, з якими це стикається щодня:

Four things this collides with every day:

  • «Мій коміт зник після rebase». Ні. Rebase не переносить коміти, він створює нові з тими самими діфами й новими батьками, тому й SHA нові. Старі об'єкти живі й досяжні через git reflog — просто на них більше не вказує гілка.
  • Після rebase чи amend push вимагає force. Це не примха: локальна гілка більше не є продовженням віддаленої, вона паралельна їй. Робити треба --force-with-lease, а не --force: перший перевіряє, що віддалена гілка з моменту твого останнього fetch не рухалась, і відмовляється, якщо колега встиг запушити. --force просто затре його роботу мовчки.
  • cherry-pick не «переносить коміт» — він створює новий коміт з тим самим діффом і новим SHA. Тому при мерджі гілки, куди ти вже щось черипікнув, можеш побачити конфлікт «на порожньому місці»: для Git це дві різні зміни, а не одна.
  • У CI ідентифікуй прогін по SHA, а не по назві гілки. Гілка рухається, SHA — ні. Артефакт, названий report-main, перезапишеться наступним пушем; report-a1b2c3d — ні. І кеш, ключований по SHA, після rebase не влучить: усі коміти нові, ключ інший, збірка піде з нуля. Це не поломка кешу, це прямий наслідок того, що коміти справді інші.
  • "My commit disappeared after the rebase." It didn't. Rebase doesn't move commits, it creates new ones with the same diffs and new parents, which is why the SHAs are new. The old objects are alive and reachable through git reflog — there's just no branch pointing at them any more.
  • After a rebase or an amend, push needs a force. That isn't a whim: the local branch is no longer a continuation of the remote one, it runs parallel to it. Use --force-with-lease, not --force: the first one checks that the remote branch hasn't moved since your last fetch and refuses if a colleague managed to push. --force will simply wipe their work in silence.
  • cherry-pick doesn't "move a commit" — it creates a new commit with the same diff and a new SHA. That's why merging a branch you've already cherry-picked out of can show a conflict out of nowhere: to Git those are two different changes, not one.
  • In CI, identify a run by SHA rather than by branch name. A branch moves, a SHA doesn't. An artifact called report-main gets overwritten by the next push; report-a1b2c3d doesn't. And a cache keyed by SHA will miss after a rebase: every commit is new, the key is different, the build starts from scratch. That isn't the cache breaking, it's a direct consequence of the commits genuinely being different.

Одна фраза, яка знімає більшість страху перед Git: будь-яка команда, що «переписує історію», насправді нічого не переписує — вона створює нові об'єкти й переставляє вказівник гілки. Старі об'єкти лишаються на диску, доки їх не забере git gc (за замовчуванням недосяжні об'єкти живуть щонайменше два тижні). Втратити роботу в Git можна практично лише двома способами: reset --hard по незакоміченому і push --force поверх чужого.

One sentence that takes away most of the fear of Git: any command that "rewrites history" doesn't actually rewrite anything — it creates new objects and moves a branch pointer. The old objects stay on disk until git gc takes them (by default unreachable objects live at least two weeks). There are practically only two ways to lose work in Git: reset --hard over uncommitted changes, and push --force over somebody else's.

Демонстрація: той самий код, той самий текст — інший SHA
Demonstration: the same code, the same message — a different SHA
$ git log --oneline -1
9f8e7d6 test(cart): cover badge counter

$ git commit --amend --no-edit    # нічого не змінили взагалі
$ git log --oneline -1
c3d4e5f test(cart): cover badge counter ← інший SHA. Дата коммітера в хеші

$ git reflog -2
c3d4e5f HEAD@{0}: commit (amend): test(cart): cover badge counter
9f8e7d6 HEAD@{1}: commit: test(cart): cover badge counter
#                    ↑ старий коміт нікуди не подівся, він просто «нічий»
$ git log --oneline -1
9f8e7d6 test(cart): cover badge counter

$ git commit --amend --no-edit    # changed nothing at all
$ git log --oneline -1
c3d4e5f test(cart): cover badge counter ← a different SHA. The committer date is in the hash

$ git reflog -2
c3d4e5f HEAD@{0}: commit (amend): test(cart): cover badge counter
9f8e7d6 HEAD@{1}: commit: test(cart): cover badge counter
#                    ↑ the old commit has not gone anywhere, it is just "orphaned"
Rebase: п'ять комітів увійшли, п'ять НОВИХ вийшли
Rebase: five commits went in, five NEW ones came out
ДО rebase                        origin/main ──▶ m3 ──▶ m2 ──▶ m1
                                                        ▲
  feat/128 ──▶ c3 ──▶ c2 ──▶ c1 ──────────────────────┘

ПІСЛЯ git rebase origin/main
                                 origin/main ──▶ m3 ──▶ m2 ──▶ m1
                                                  ▲
  feat/128 ──▶ c3' ──▶ c2' ──▶ c1' ──────────────┘
               ▲
               └── ІНШІ об'єкти з іншими SHA: батьки змінилися,
                   а батько входить у хеш. Дифф той самий,
                   коміт — інший. c1, c2, c3 лежать у reflog.

$ git push --force-with-lease
#            ↑ відмовиться, якщо колега встиг запушити в цю гілку
#              після твого останнього fetch. --force не перевіряє нічого
BEFORE rebase                   origin/main ──▶ m3 ──▶ m2 ──▶ m1
                                                        ▲
  feat/128 ──▶ c3 ──▶ c2 ──▶ c1 ──────────────────────┘

AFTER git rebase origin/main
                                 origin/main ──▶ m3 ──▶ m2 ──▶ m1
                                                  ▲
  feat/128 ──▶ c3' ──▶ c2' ──▶ c1' ──────────────┘
               ▲
               └── DIFFERENT objects with different SHAs: the parents changed,
                   and the parent is part of the hash. Same diff,
                   different commit. c1, c2, c3 sit in the reflog.

$ git push --force-with-lease
#            ↑ refuses if a teammate managed to push to this branch
#              after your last fetch. --force checks nothing at all
🔧 Команди й прапорці в цьому прикладі git commit --amend --no-edit — переробити останній коміт, не відкриваючи редактор повідомлення; git push --force-with-lease — безпечна форма force-push, перевіряє, що віддалена гілка не рухалась після твого останнього fetch; git reflog -N — останні N записів журналу HEAD; git rev-parse --short HEAD — короткий SHA поточного коміта, зручно як ключ артефакта в CI (у GitHub Actions те саме доступне як github.sha).
🔧 Commands and flags in this example git commit --amend --no-edit — redo the last commit without opening the message editor; git push --force-with-lease — the safe form of force-push, it checks that the remote branch hasn't moved since your last fetch; git reflog -N — the last N entries of the HEAD journal; git rev-parse --short HEAD — the short SHA of the current commit, handy as an artifact key in CI (in GitHub Actions the same thing is available as github.sha).
🗣 Як читати вголос SHA коміта — це хеш від вмісту самого об'єкта, а у вміст входить кореневе дерево, список батьків, автор із датою, коммітер із власною датою і повідомлення. Тому commit --amend без жодних змін дає інший SHA: змінилася дата коммітера, а вона всередині хешу. Ще важливіше, що батько теж входить у вміст, отже виправлення коміта в середині гілки міняє SHA всіх наступних — історія це ланцюг, спаяний хешами, і підмінити ланку посередині непомітно неможливо. Практичних наслідків чотири. Перший: коміт не зникає після rebase — rebase створює нові коміти з тими самими діфами й новими батьками, а старі лежать у reflog. Другий: після rebase чи amend потрібен force-push, і робити його треба через --force-with-lease, який відмовиться, якщо колега встиг запушити. Третій: cherry-pick не переносить коміт, а створює новий з тим самим діффом, тому при подальшому мерджі можна отримати конфлікт наче на порожньому місці. Четвертий: у CI прогін і артефакти треба ідентифікувати по SHA, а не по назві гілки — гілка рухається, SHA ні. І загальний висновок: команди, які нібито переписують історію, насправді нічого не переписують, вони створюють нові об'єкти й переставляють вказівник, тож реально втратити роботу можна лише через reset --hard по незакоміченому і force-push поверх чужого.
🗣 How to say it out loud A commit's SHA is a hash of the object's own content, and that content is the root tree, the parent list, the author with a date, the committer with their own date, and the message. So commit --amend with no changes at all still gives a different SHA, because the committer date moved and that date lives inside the hash. More importantly, the parent is part of the content too, so fixing a commit in the middle of a branch changes the SHA of everything after it — history is a chain welded by hashes, and you can't swap a link in the middle unnoticed. There are four practical consequences. One: a commit doesn't vanish after a rebase; rebase creates new commits with the same diffs and new parents, and the old ones are sitting in the reflog. Two: after a rebase or an amend you need a force push, and I do it with --force-with-lease, which refuses if a colleague pushed in the meantime. Three: cherry-pick doesn't move a commit, it creates a new one with the same diff, which is why a later merge can conflict seemingly out of nowhere. Four: in CI I key runs and artifacts by SHA rather than by branch name, because the branch moves and the SHA doesn't. And the general takeaway is that the commands which supposedly rewrite history don't rewrite anything — they create new objects and move a pointer — so realistically you can only lose work through a hard reset over uncommitted changes, or a force push over someone else's.
▶ 2 7
B1 Що таке гілка в Git і чому вона майже нічого не коштує?What a branch actually is in Git, and why it costs almost nothing
ГілкиBranches Junior ▶

Найпоширеніша хибна картинка в голові — що гілка це копія проєкту. Копії немає. Гілка — це маленький файл у .git/refs/heads/, і всередині нього лежить хеш одного коміту. Все, це вся гілка цілком.

The most common wrong picture in people's heads is that a branch is a copy of the project. There is no copy. A branch is a small file in .git/refs/heads/ with the hash of one commit inside it. That's it — that's the whole branch.

Щоб картинка склалась, потрібні три поняття:

Three ideas make the picture click:

  • Коміт — знімок усього дерева файлів плюс посилання на батьківський коміт. Не «різниця», саме знімок; різницю Git рахує на льоту, коли ти її просиш.
  • Гілка — рухомий вказівник на останній коміт своєї лінії.
  • HEAD — вказівник на те, де ти зараз стоїш. Зазвичай він показує не на коміт, а на гілку.
  • A commit — a snapshot of the whole file tree plus a link to the parent commit. Not a "difference", a snapshot; Git works the difference out on the fly when you ask for it.
  • A branch — a moving pointer to the last commit on its own line.
  • HEAD — a pointer to where you are standing right now. Usually it points at a branch rather than at a commit.

Звідси випливає все, що спершу здається магією. Створення гілки миттєве навіть у репозиторії з п'ятьма тисячами тестів — бо це запис одного рядка у файл, а не копіювання каталогу. Коли ти комітиш, уперед зсувається тільки та гілка, на яку дивиться HEAD; решта вказівників лишаються там, де були.

Everything that looks like magic at first follows from that. Creating a branch is instant even in a repository with five thousand tests — because it writes one line into a file instead of copying a directory. When you commit, only the branch HEAD is looking at moves forward; the other pointers stay where they were.

Саме тому робота в твоїй гілці фізично не може зламати чужу. Ви обидва тільки дописуєте нові об'єкти в спільну базу; ніхто нічого не редагує на місці. Зламати чуже можна лише свідомо зсунувши чужий вказівник — і це вже окрема розмова про force push.

That's why work in your branch physically cannot break someone else's. You are both only appending new objects to a shared store; nobody edits anything in place. The only way to break somebody else's work is to move their pointer deliberately — and that's a separate conversation about force push.

switch чи checkout? Історично checkout робить дві різні роботи — і перемикає гілки, і відновлює файли з коміту. Через це один одрук у команді міг мовчки затерти незбережену правку. У Git 2.23 це розділили: git switch для гілок, git restore для файлів. Для співбесіди знай обидва, у роботі бери switch.

switch or checkout? Historically checkout does two different jobs — it moves between branches and it restores files from a commit. Because of that, one typo in the command could silently wipe an unsaved edit. Git 2.23 split them: git switch for branches, git restore for files. Know both for the interview, use switch at work.

Git — гілка це файл, у якому лежить один хеш
Git — a branch is a file with one hash inside it
git switch -c test/checkout-flow
// Switched to a new branch 'test/checkout-flow'

cat .git/refs/heads/test/checkout-flow
// 9f2c1a4e7b3d5f18a02c6e91d4b7f3a8c5e2d016   ← і це вся гілка

git log --oneline --graph --all
// * 9f2c1a4 (HEAD -> test/checkout-flow) test: порожній кошик
// * 4d8b210 (main, origin/main) feat: поле промокоду
// * 1a03e77 chore: playwright 1.49
git switch -c test/checkout-flow
// Switched to a new branch 'test/checkout-flow'

cat .git/refs/heads/test/checkout-flow
// 9f2c1a4e7b3d5f18a02c6e91d4b7f3a8c5e2d016   ← and that is the whole branch

git log --oneline --graph --all
// * 9f2c1a4 (HEAD -> test/checkout-flow) test: empty cart
// * 4d8b210 (main, origin/main) feat: promo code field
// * 1a03e77 chore: playwright 1.49
Схема — що насправді зсувається при коміті
Diagram — what actually moves when you commit
        A ─── B ─── C                    main
                     \
                      D ─── E            test/checkout-flow  ← HEAD

main               = вказівник на C
test/checkout-flow = вказівник на E
HEAD               = вказівник на гілку test/checkout-flow

git commit тут створить F і зсуне на нього ТІЛЬКИ test/checkout-flow.
main як стояв на C, так і стоятиме — його ніхто не чіпав.
        A ─── B ─── C                    main
                     \
                      D ─── E            test/checkout-flow  ← HEAD

main               = pointer to C
test/checkout-flow = pointer to E
HEAD               = pointer to the branch test/checkout-flow

git commit here creates F and moves ONLY test/checkout-flow onto it.
main stays on C, as it always did — nobody touched it.
🔧 Git-патерни в цьому прикладі git switch -c <назва> — створити гілку від поточного коміту й одразу на неї перейти; git branch --show-current — дізнатись, де ти; git log --oneline --graph --all — побачити форму історії, а не список рядків; .git/refs/heads/ — каталог, де гілки лежать буквально як файли.
🔧 Git patterns in this example git switch -c <name> — create a branch at the current commit and move onto it straight away; git branch --show-current — find out where you are; git log --oneline --graph --all — see the shape of history rather than a list of lines; .git/refs/heads/ — the directory where branches literally sit as files.
🗣 Як читати вголос Гілка в Git — це не копія проєкту, а звичайний маленький файл, у якому лежить хеш одного коміту. Щоб це склалось у картинку, потрібні три поняття: коміт — це знімок усього дерева файлів плюс посилання на батька, гілка — це рухомий вказівник на останній коміт своєї лінії, а HEAD — вказівник на те, де ти зараз стоїш. Звідси все й випливає: створення гілки миттєве навіть на величезному репозиторії, бо ти просто записуєш хеш у файл, а не копіюєш каталог. Коли ти робиш коміт, уперед зсувається тільки та гілка, на яку дивиться HEAD — решта лишаються на місці. Саме тому робота в моїй гілці фізично не може зламати чужу: ми обидва тільки дописуємо нові об'єкти, ніхто нічого не редагує на місці. І окремо варто сказати про switch і checkout: checkout історично робить дві різні роботи — перемикає гілки і відновлює файли — тому в Git 2.23 його розділили на switch і restore, і в роботі краще брати switch.
🗣 How to say it out loud A branch in Git isn't a copy of the project, it's an ordinary small file with the hash of one commit inside it. Three ideas make that click: a commit is a snapshot of the whole file tree plus a link to its parent, a branch is a moving pointer to the last commit on its line, and HEAD is a pointer to where I'm standing right now. Everything else follows from there — creating a branch is instant even on a huge repo, because I'm writing a hash into a file, not copying a directory. When I commit, only the branch HEAD is looking at moves forward, and the rest stay exactly where they were. That's also why work in my branch physically can't break somebody else's: we're both only appending new objects, nobody edits anything in place. And it's worth mentioning switch versus checkout — checkout historically does two different jobs, moving branches and restoring files, so Git 2.23 split it into switch and restore, and at work I'd reach for switch.
B2 Звідки починати гілку — і чому «застарілий» старт сам породжує конфлікти?Where to start a branch from — and why a stale start breeds conflicts by itself
ГілкиBranches Junior ▶

Щоденний цикл у тест-проєкті короткий і завжди однаковий: перейти на main, підтягнути свіже, звідти створити гілку, накомітити, запушити з -u, відкрити PR, після мержу видалити гілку локально й на сервері.

The daily cycle in a test project is short and always the same: move onto main, pull the fresh state, create a branch from there, commit, push with -u, open a PR, and after the merge delete the branch locally and on the server.

Ключове — перший крок. Гілка створюється від того коміту, на якому ти стоїш зараз. Якщо ти не оновив main, ти щойно почав роботу від стану двотижневої давності, і Git про це не попередить.

The key part is the first step. A branch is created from the commit you are standing on right now. If you didn't update main, you have just started work from a state two weeks old, and Git will not warn you about it.

Що з цього виростає:

What grows out of that:

За ці два тижні колега переніс pages/checkout.page.ts і перейменував половину методів. Твоя гілка про це не знає — вона пише тести проти старого API. У день мержу Git мусить помирити два тижні розбіжності одним махом, і ти отримуєш десять конфліктів замість нуля. Це не Git зіпсувався — це ти відклав зустріч, а вона все одно відбулась, просто пізніше й уся одразу.

Over those two weeks a colleague moved pages/checkout.page.ts and renamed half the methods. Your branch knows nothing about it — it's writing tests against the old API. On merge day Git has to reconcile two weeks of divergence in one go, and you get ten conflicts instead of none. Git didn't break — you postponed the meeting, and it happened anyway, just later and all at once.

Друга половина того самого правила — тримати гілку живою. Не «створив і забув на три тижні», а регулярно вливати в неї main. Реальна метрика конфліктності — це не розмір команди й не кількість гілок, а час життя гілки. Гілка на два дні конфліктує трохи. Гілка на три тижні конфліктує боляче, і майже завжди в найгірший момент.

The other half of the same rule is keeping the branch alive. Not "created it and forgot it for three weeks", but pulling main into it regularly. The real measure of how conflict-prone the work is isn't team size and isn't the number of branches, it's how long a branch lives. A two-day branch conflicts a little. A three-week branch conflicts painfully, and almost always at the worst possible moment.

fetch проти pull: fetch тільки завантажує з сервера нові коміти й оновлює origin/main — твої файли не змінюються, нічого не ламається, це безпечна команда. pull — це fetch плюс автоматичний мерж у поточну гілку. Тому pull може дати конфлікт, а fetch — ніколи.

fetch versus pull: fetch only downloads new commits from the server and updates origin/main — your files don't change, nothing breaks, it's a safe command. pull is fetch plus an automatic merge into the current branch. That's why pull can produce a conflict and fetch never can.

Git — цикл однієї задачі від початку до видалення гілки
Git — one task's cycle, from the start to deleting the branch
git switch main
git pull                                  // ← крок, який пропускають
git switch -c test/431-checkout-empty-cart

// … пишемо тести, комітимо маленькими кроками
git commit -m "test(checkout): порожній кошик не пускає на оплату"

git push -u origin test/431-checkout-empty-cart
// -u прив'язує локальну гілку до віддаленої: далі досить `git push`

// поки PR висить — раз на день підтягуємо main до себе
git fetch origin
git merge origin/main

// після мержу PR — прибрати за собою
git switch main && git pull
git branch -d test/431-checkout-empty-cart
git fetch --prune                        // прибрати мертві origin/*
git switch main
git pull                                  // ← the step people skip
git switch -c test/431-checkout-empty-cart

// … write tests, commit in small steps
git commit -m "test(checkout): empty cart cannot proceed to payment"

git push -u origin test/431-checkout-empty-cart
// -u links the local branch to the remote one: after that `git push` is enough

// while the PR is open — pull main into your branch once a day
git fetch origin
git merge origin/main

// after the PR is merged — clean up after yourself
git switch main && git pull
git branch -d test/431-checkout-empty-cart
git fetch --prune                        // drop dead origin/* refs
Схема — ціна застарілого старту
Diagram — the price of a stale start
СТАРТ ВІД СВІЖОГО main:

  A ─ B ─ C ─ D ─ E ─ F ─ G          main
                          \
                           t1 ─ t2   test/…   розбіжність: 2 коміти

СТАРТ ВІД main ДВОТИЖНЕВОЇ ДАВНОСТІ:

  A ─ B ─ C ─ D ─ E ─ F ─ G          main
          \
           t1 ─ t2                   test/…   розбіжність: 6 комітів

Конфлікт у момент мержу — це не подія, а накопичений борг.
Розмір боргу = скільки чужих комітів проїхало повз твою гілку.
START FROM A FRESH main:

  A ─ B ─ C ─ D ─ E ─ F ─ G          main
                          \
                           t1 ─ t2   test/…   divergence: 2 commits

START FROM A TWO-WEEK-OLD main:

  A ─ B ─ C ─ D ─ E ─ F ─ G          main
          \
           t1 ─ t2                   test/…   divergence: 6 commits

A conflict at merge time is not an event, it is accumulated debt.
Size of the debt = how many foreign commits went past your branch.
🔧 Git-патерни в цьому прикладі git pull перед switch -c — стартувати від свіжого; push -u — прив'язати локальну гілку до віддаленої один раз; git fetch + git merge origin/main — оновити гілку без сюрпризів; branch -d (мала літера — відмовиться видаляти незмержене) проти -D (велика — видалить мовчки); fetch --prune — прибрати посилання на віддалені гілки, яких уже нема. Назва гілки — <тип>/<номер задачі>-<слаг>, як у решті репозиторію.
🔧 Git patterns in this example git pull before switch -c — start from something fresh; push -u — link the local branch to the remote one once; git fetch + git merge origin/main — update the branch with no surprises; branch -d (lowercase — it refuses to delete anything unmerged) versus -D (uppercase — it deletes in silence); fetch --prune — drop references to remote branches that no longer exist. The branch name is <type>/<issue number>-<slug>, as everywhere else in the repository.
🗣 Як читати вголос Цикл простий: перейшов на main, підтягнув свіже, звідти створив гілку, накомітив, запушив з мінус u, відкрив PR, після мержу видалив гілку. Найважливіший тут — перший крок, і саме його найчастіше пропускають. Гілка створюється від того коміту, на якому ти стоїш зараз, і якщо main у тебе двотижневої давності, то ти щойно почав роботу від стану двотижневої давності, а Git тебе про це не попередить. Далі це вилазить у день мержу: колега за ці два тижні переніс пейдж-обджект, твоя гілка про це не знає, і Git мусить помирити два тижні розбіжності одним махом. Тому друга половина того самого правила — тримати гілку живою: раз на день вливати в неї main, а не створити й забути на три тижні. Реальна метрика конфліктності — це не розмір команди, а час життя гілки: гілка на два дні конфліктує трохи, гілка на три тижні конфліктує боляче. І варто розрізняти fetch і pull: fetch тільки завантажує нові коміти й оновлює origin-посилання, він безпечний і конфлікт дати не може, а pull — це fetch плюс автоматичний мерж у поточну гілку, і саме тому pull може конфліктувати.
🗣 How to say it out loud The cycle is simple: move onto main, pull, create the branch from there, commit, push with dash u, open the PR, and delete the branch after the merge. The important step is the first one, and it's the one people skip. A branch is created from the commit you're standing on, so if your main is two weeks old you've just started work from a two-week-old state, and Git won't say a word about it. It shows up on merge day: a colleague moved the page object in the meantime, your branch has no idea, and Git has to reconcile two weeks of divergence in one go. So the other half of the rule is keeping the branch alive — pulling main into it once a day rather than creating it and forgetting it for three weeks. The real metric here isn't team size, it's how long the branch lives: two days conflicts a little, three weeks conflicts painfully. And I'd separate fetch from pull — fetch only downloads commits and updates the origin refs, so it can't conflict, while pull is fetch plus an automatic merge into the current branch, which is exactly why pull can.
B3 Як розв'язати конфлікт і не загубити при цьому чужу зміну?How to resolve a conflict without quietly losing somebody else's change
ГілкиBranches Middle ▶

Конфлікт — це не помилка й не поломка. Це Git, який каже: двоє змінили ті самі рядки, відштовхуючись від спільної основи, і я не візьму на себе право вирішити, чия правда. Усе інше він мирить сам, мовчки.

A conflict isn't an error and isn't a breakage. It's Git saying: two people changed the same lines starting from a common base, and I'm not going to take it on myself to decide who's right. Everything else it reconciles on its own, silently.

Небезпечний інстинкт

The dangerous instinct

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

The most common way to "resolve" a conflict is to take one side wholesale, because after that the code compiles and CI is green. The problem is that green CI proves nothing here: you may have just quietly deleted somebody's fix, and the test that would have caught it hasn't been written yet — that colleague was in the middle of writing it.

Правило, яке варто вміти сформулювати вголос: розв'язати конфлікт — це не вибрати бік, а написати версію, яка зберігає намір обох сторін. Дуже часто правильна відповідь — це третій текст, якого немає ні в тебе, ні в них.

A rule worth being able to say out loud: resolving a conflict isn't picking a side, it's writing the version that preserves the intent of both sides. Very often the right answer is a third text that exists neither in yours nor in theirs.

Побачити основу, а не два готові тексти

See the base, not two finished texts

За замовчуванням Git показує тільки дві фінальні версії, і доводиться вгадувати, хто що змінив. merge.conflictStyle = zdiff3 додає третій блок — спільну основу. Одразу видно: цей бік додав рядок, а той перейменував метод — і рішення стає очевидним замість «на око схоже».

By default Git shows you only the two final versions, and you're left guessing who changed what. merge.conflictStyle = zdiff3 adds a third block — the common base. Straight away you can see that this side added a line while that one renamed a method, and the decision becomes obvious instead of "looks about right".

Пастка з --ours і --theirs, на якій ловляться навіть досвідчені

The --ours / --theirs trap that catches even experienced people

У git merge «ours» — це гілка, на якій ти стоїш, «theirs» — та, яку ти вливаєш. Логічно. У git rebase це перевертається: там твої коміти по одному накладаються на чужу гілку, тому «ours» — це та гілка, на яку ти перебазовуєшся, а «theirs» — твій власний коміт. Хто вивчив --ours на мержі й механічно застосував його під час rebase, викидає рівно ту зміну, яку хотів зберегти.

In git merge, "ours" is the branch you're standing on and "theirs" is the one you're pulling in. Logical. In git rebase it flips: there your commits are replayed one by one onto somebody else's branch, so "ours" is the branch you're rebasing onto and "theirs" is your own commit. Anyone who learned --ours on a merge and then applied it mechanically during a rebase throws away exactly the change they meant to keep.

Аварійний вихід є завжди. Поки ти не закомітив розв'язання, git merge --abort або git rebase --abort повертають усе рівно туди, де воно було. Конфлікт — це стан, з якого можна просто вийти, а не двері, що зачинились за спиною.

There is always an emergency exit. As long as you haven't committed the resolution, git merge --abort or git rebase --abort puts everything back exactly where it was. A conflict is a state you can simply step out of, not a door that locked behind you.

Git — конфлікт у стилі zdiff3: видно основу, а не лише два результати
Git — a conflict in zdiff3 style: you see the base, not only the two results
git config --global merge.conflictStyle zdiff3

// tests/checkout.spec.ts
<<<<<<< HEAD                    ← мій бік
await checkout.pay({ timeout: 15000 });
||||||| основа                    ← від чого обидва відштовхувались
await checkout.pay();
=======
await checkout.submitPayment();
>>>>>>> main                    ← чужий бік

// Що видно з основи:
//   я  — додав таймаут
//   він — перейменував метод
// Жоден бік окремо не є відповіддю. Відповідь — третій текст:
await checkout.submitPayment({ timeout: 15000 });
git config --global merge.conflictStyle zdiff3

// tests/checkout.spec.ts
<<<<<<< HEAD                    ← my side
await checkout.pay({ timeout: 15000 });
||||||| base                      ← what both sides started from
await checkout.pay();
=======
await checkout.submitPayment();
>>>>>>> main                    ← their side

// What the base tells us:
//   me  — added a timeout
//   them — renamed the method
// Neither side alone is the answer. The answer is a third text:
await checkout.submitPayment({ timeout: 15000 });
Git — робочий порядок дій
Git — the working order of operations
git status                      // які саме файли в конфлікті
git diff                        // показує ТІЛЬКИ конфліктні місця

// … руками пишемо версію, що зберігає намір обох …

npx playwright test tests/checkout.spec.ts   // ← не пропускати
git add tests/checkout.spec.ts
git merge --continue

// якщо стало зовсім незрозуміло — вийти без наслідків:
git merge --abort          // або rebase --abort
git status                      // which files exactly are in conflict
git diff                        // shows ONLY the conflicting spots

// … write by hand a version that keeps both intents …

npx playwright test tests/checkout.spec.ts   // ← do not skip
git add tests/checkout.spec.ts
git merge --continue

// if it gets completely confusing — back out with no consequences:
git merge --abort          // or rebase --abort
🔧 Git-патерни в цьому прикладі merge.conflictStyle = zdiff3 — показувати спільну основу поруч із двома версіями; git diff під час конфлікту показує лише конфліктні ділянки; git add — це і є «я розв'язав цей файл»; --continue / --abort — дві однакові пари для merge й rebase; git rerere (reuse recorded resolution) — запам'ятовує розв'язання й підставляє його, коли той самий конфлікт трапляється вдруге, що рятує на довгому rebase.
🔧 Git patterns in this example merge.conflictStyle = zdiff3 — show the common base next to the two versions; git diff during a conflict shows only the conflicting regions; git add is literally "I have resolved this file"; --continue / --abort — the same pair for merge and for rebase; git rerere (reuse recorded resolution) — remembers a resolution and replays it when the same conflict turns up a second time, which is what saves you on a long rebase.
🗣 Як читати вголос Конфлікт — це не помилка, це Git, який каже: двоє змінили ті самі рядки від спільної основи, і я не буду вгадувати, чия правда. Найнебезпечніший інстинкт тут — вибрати один бік цілком, бо після цього код компілюється й CI зелений. Але зелений CI нічого не доводить: ти міг щойно тихо видалити чужий фікс, а тест, який би це зловив, ще не написаний — його якраз і писав той колега. Тому правило таке: розв'язати конфлікт означає не вибрати бік, а написати версію, яка зберігає намір обох, і дуже часто це третій текст, якого не було ні в тебе, ні в них. Дуже допомагає стиль конфлікту zdiff3 — він показує ще й спільну основу, і одразу видно, що один бік додав таймаут, а другий перейменував метод. Окремо варто знати пастку з ours і theirs: у merge ours — це гілка, на якій ти стоїш, а в rebase це перевертається, бо там твої коміти накладаються на чужу гілку, і ours стає чужою гілкою. І останнє: поки розв'язання не закомічене, merge abort або rebase abort повертають усе точно на місце, тому виходити з конфлікту завжди можна.
🗣 How to say it out loud A conflict isn't an error, it's Git saying that two people changed the same lines from a common base and it isn't going to guess who's right. The most dangerous instinct is to take one side wholesale, because after that the code compiles and CI goes green. But green CI proves nothing here — I might have just quietly deleted someone's fix, and the test that would have caught it doesn't exist yet, because that colleague was busy writing it. So the rule I'd state out loud is that resolving a conflict isn't picking a side, it's writing a version that keeps both intents, and very often that's a third text neither of us had. The zdiff3 conflict style helps a lot here, because it shows the common base as well, and then it's obvious that one side added a timeout while the other renamed a method. One trap worth knowing is ours and theirs: in a merge, ours is the branch you're on, but in a rebase it flips, because your commits are being replayed onto the other branch, so ours becomes their branch. And until the resolution is committed, merge --abort or rebase --abort puts everything back exactly where it was, so you can always walk out of a conflict.
B4 git stash — що він робить насправді і де кусає?git stash — what it really does, and where it bites
ГілкиBranches Middle ▶

Stash виглядає як «кишеня, куди тимчасово сховати правки», але всередині це звичайні коміти — просто на службовому вказівнику refs/stash, поза будь-якою гілкою. Робоче дерево після цього відкочується до HEAD. Це стек, а не тека: stash@{0} завжди останній покладений.

Stash looks like "a pocket to tuck your edits into for a moment", but underneath it is ordinary commits — just on a service pointer, refs/stash, outside any branch. The working tree then rolls back to HEAD. It's a stack, not a folder: stash@{0} is always the one you put in last.

Де він кусає — чотири місця, всі тихі

Where it bites — four places, all of them quiet

1. Нові файли за замовчуванням не забирає. Ти створив fixtures/new-user.json і ще не зробив git add. Для Git цей файл не існує, тому git stash лишає його на місці. Ти перемикаєшся на іншу гілку — і фікстура їде з тобою, невидимо змінюючи прогони там. Потрібен -u. І окремо: -a забирає ще й ігноровані файли, тобто може згребти все, що в .gitignore — на тест-проєкті це буває дуже багато.

1. It doesn't take new files by default. You created fixtures/new-user.json and haven't run git add yet. As far as Git is concerned that file doesn't exist, so git stash leaves it where it is. You move to another branch — and the fixture travels with you, invisibly changing the runs over there. You need -u. And separately: -a also takes ignored files, so it can sweep up everything in .gitignore — in a test project that can be an enormous amount.

2. pop теж уміє конфліктувати. Гілка встигла з'їхати вперед — і повернення правок породжує ту саму механіку конфлікту, тільки без зрозумілого контексту «що з чим мирять». Важлива деталь: при конфлікті запис у стеші не видаляється. Люди цього не знають, панікують, роблять git checkout . «щоб почистити» — і втрачають уже зроблене розв'язання.

2. pop can conflict too. The branch has moved on in the meantime, and putting the edits back produces the same conflict machinery, only without the clear context of what is being reconciled with what. An important detail: on a conflict the stash entry is not deleted. People don't know that, panic, run git checkout . "to clean things up" — and lose the resolution they had already done.

3. Стек анонімний. Через тиждень stash@{0}, stash@{1}, stash@{2} нічого не означають, і ніхто вже не пам'ятає, що там. Лікується одним звичкою: завжди git stash push -m "…".

3. The stack is anonymous. A week later stash@{0}, stash@{1} and stash@{2} mean nothing, and nobody remembers what's in them. One habit cures it: always git stash push -m "…".

4. Він невидимий для всіх, включно з тобою. Stash не пушиться, не потрапляє в PR, не бекапиться. Мертвий ноутбук забирає стеш із собою, і жодного сліду на сервері не лишається.

4. It is invisible to everyone, yourself included. A stash isn't pushed, doesn't show up in a PR, isn't backed up. A dead laptop takes the stash with it and leaves no trace at all on the server.

Чесна порада: stash — інструмент на десять хвилин. «Терміново глянь баг на проді» — так. Усе, що довше, краще класти WIP-комітом у гілку: він має назву, його видно, його можна запушити, і його потім легко причесати через commit --amend чи інтерактивний rebase. До того ж сучасний git switch сам переносить незакомічені правки на іншу гілку, якщо вони не конфліктують — половина причин братися за stash уже відпала.

Honest advice: stash is a ten-minute tool. "Go and look at a prod bug right now" — yes. Anything longer is better kept as a WIP commit on a branch: it has a name, it's visible, it can be pushed, and it's easy to tidy up afterwards with commit --amend or an interactive rebase. On top of that, modern git switch carries uncommitted edits onto another branch by itself when they don't conflict — half the reasons to reach for stash have already gone.

Git — stash як він є
Git — stash as it really is
git stash push -u -m "половина тесту на промокод"
//        ↑ -u бере й нові файли, яких ще нема в індексі

git stash list
// stash@{0}: On test/431-promo: половина тесту на промокод
// stash@{1}: WIP on main: 4d8b210 feat: поле промокоду   ← а це що?

git stash show -p stash@{1}     // подивитись, не розпаковуючи

git stash pop                    // повернути й прибрати зі стека
git stash apply stash@{1}       // повернути, але лишити запис

// Якщо pop дав конфлікт — запис У СТЕШІ ЩЕ Є.
// Спокійно розв'язуєш, git add, і аж тоді:
git stash drop stash@{0}
git stash push -u -m "half of the promo code test"
//        ↑ -u also takes new files that are not in the index yet

git stash list
// stash@{0}: On test/431-promo: half of the promo code test
// stash@{1}: WIP on main: 4d8b210 feat: promo code field   ← and what is this?

git stash show -p stash@{1}     // look without unpacking

git stash pop                    // restore and remove from the stack
git stash apply stash@{1}       // restore but keep the entry

// If pop hit a conflict — the entry IS STILL IN THE STASH.
// Resolve it calmly, git add, and only then:
git stash drop stash@{0}
Схема — чому stash це стек, а не тека
Diagram — why stash is a stack and not a folder
  git stash ×3               git stash pop

  stash@{0}  ← третій      stash@{0}  ← другий  (був @{1})
  stash@{1}  ← другий      stash@{1}  ← перший  (був @{2})
  stash@{2}  ← перший

Номери зсуваються після кожного pop / drop.
Тому «зроблю pop для stash@{2}» через день — це вже інший stash.
Звідси й правило: -m з описом, або взагалі WIP-коміт у гілці.
  git stash ×3               git stash pop

  stash@{0}  ← third       stash@{0}  ← second  (was @{1})
  stash@{1}  ← second      stash@{1}  ← first   (was @{2})
  stash@{2}  ← first

Indexes shift after every pop / drop.
So "I'll pop stash@{2}" a day later is already a different stash.
Hence the rule: -m with a description, or just a WIP commit on the branch.
🔧 Git-патерни в цьому прикладі stash push -m замість голого git stash — щоб запис мав назву; -u (untracked) проти -a (плюс ігноровані); stash show -p — переглянути вміст без розпакування; pop (взяти й прибрати) проти apply (взяти й лишити) — apply безпечніший, коли не певен; git stash list — єдине місце, де стеш узагалі видно.
🔧 Git patterns in this example stash push -m instead of a bare git stash — so the entry has a name; -u (untracked) versus -a (plus ignored files); stash show -p — look at the contents without unpacking them; pop (take it and drop it) versus apply (take it and keep it) — apply is safer when you aren't sure; git stash list — the only place a stash is visible at all.
🗣 Як читати вголос Stash виглядає як кишеня для тимчасових правок, але всередині це звичайні коміти на службовому вказівнику refs slash stash, поза будь-якою гілкою, а робоче дерево після цього відкочується до HEAD. Кусає він у чотирьох місцях, і всі вони тихі. Перше — нові файли за замовчуванням не забираються, бо для Git вони ще не існують, тому фікстура, яку ти щойно створив, поїде з тобою на іншу гілку й непомітно змінить прогони; для цього є прапорець мінус u. Друге — pop теж уміє конфліктувати, і при конфлікті запис у стеші не видаляється, а люди цього не знають, роблять checkout крапка щоб почистити, і втрачають уже зроблене розв'язання. Третє — стек анонімний, через тиждень stash нуль і stash один нічого не означають, тому завжди варто класти з мінус m і описом. Четверте — стеш невидимий для всіх: він не пушиться, не потрапляє в PR, не бекапиться, і мертвий ноутбук забирає його з собою. Тому чесна порада така: stash — це інструмент на десять хвилин, а все, що довше, краще класти WIP-комітом у гілку, бо його видно, його можна запушити і потім причесати.
🗣 How to say it out loud Stash looks like a pocket for temporary edits, but underneath it's ordinary commits on a service pointer called refs stash, outside any branch, and the working tree rolls back to HEAD afterwards. It bites in four places and all of them are quiet. First, it doesn't take new files by default, because as far as Git is concerned they don't exist yet, so a fixture you've just created travels with you to the other branch and silently changes the runs there — that's what the dash u flag is for. Second, pop can conflict too, and on a conflict the stash entry isn't deleted; people don't know that, run checkout dot to clean up, and lose the resolution they'd already done. Third, the stack is anonymous — a week later stash zero and stash one mean nothing, so I always push with dash m and a description. Fourth, a stash is invisible to everybody: it isn't pushed, it isn't in a PR, it isn't backed up, and a dead laptop takes it with it. So my honest advice is that stash is a ten-minute tool, and anything longer belongs in a WIP commit on a branch, because that's visible, pushable and easy to tidy up later.
B5 Стратегії гілкування очима QA — що обрати і за яким критерієм?Branching strategies through a QA lens — which one to pick, and by what criterion
ГілкиBranches Senior ▶

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

In interviews people usually answer this by drawing diagrams. The diagrams aren't the interesting part — what's interesting is what each strategy does to testing: where the regression suite lives, what state it runs against, and how many times you end up verifying the same fix.

Trunk-based

Trunk-based

Усі коротко живуть у гілках на години, недороблене ховається за фіче-флагами. Для QA: «поточний стан» існує рівно в одному місці, регресія постійно біжить по main, зате предметом тестування стають самі флаги — комбінаторика ввімкнено/вимкнено, яку легко недооцінити. Ручну регресію перед кожним мержем сюди не вставиш фізично: стратегія працює тільки на сильній автоматизації.

Everybody lives in branches that last hours, and unfinished work hides behind feature flags. For QA: "the current state" exists in exactly one place and the regression suite runs against main continuously, but the flags themselves become the thing under test — an on/off combinatorics that is easy to underestimate. You physically cannot slot a manual regression pass in before every merge here: the strategy only works on strong automation.

GitHub Flow

GitHub Flow

main завжди придатний до релізу, одна гілка на задачу, PR із перевірками, деплой із main. Для QA це найзручніший варіант, бо PR стає воротами якості: branch protection плюс обов'язкові checks — це той момент, коли твій сьют перестає бути порадою й отримує право не пустити зміну. Дефолт для більшості команд і для наших репозиторіїв.

main is always releasable, one branch per task, PRs with checks, deploys from main. For QA this is the most comfortable option, because the PR becomes the quality gate: branch protection plus required checks is the moment your suite stops being advice and gets the right to block a change. The default for most teams, and for our repositories.

Git Flow (develop + release/* + hotfix/*)

Git Flow (develop + release/* + hotfix/*)

Створювався для софту, який виходить версіями й партіями. Для QA плюс реальний: гілка release/* дає вікно стабілізації — заморожений стан, по якому можна спокійно пройти ручну регресію, поки розробка їде далі в develop. Ціна теж реальна: постійний подвійний мерж, довга розбіжність гілок і фікси, які треба нести в двох напрямках. Якщо ви деплоїте кілька разів на день — це чиста накладна вартість.

Built for software that ships in versions and batches. The QA upside is real: a release/* branch gives you a stabilisation window — a frozen state you can walk a manual regression through while development carries on in develop. The cost is just as real: a permanent double merge, branches that diverge for a long time, and fixes that have to be carried in two directions. If you deploy several times a day, that's pure overhead.

Довгоживучі release-гілки

Long-lived release branches

Неминучі, коли клієнти сидять на кількох версіях одночасно (класика on-prem). Для QA це найдорожча схема: кожен фікс тестується N разів, cherry-pick у кожну підтримувану версію, і тестові дані множаться на кількість версій.

Unavoidable when customers sit on several versions at once — the classic on-prem situation. For QA it's the most expensive scheme of all: every fix is tested N times, cherry-picked into every supported version, and the test data multiplies by the number of versions.

Судження, заради якого це питання й ставлять: стратегія — це функція від частоти деплою і кількості версій, живих у проді. Не від розміру команди, не від смаку тімліда. Деплоїте щодня й версія одна — GitHub Flow. Релізите за розкладом і потрібне вікно стабілізації — Git Flow або release-гілки. Деплоїте безперервно й автоматизація сильна — trunk-based. І окремий QA-критерій, який рідко звучить: де саме біжить регресія і проти якого стану. Якщо у стратегії немає очевидної відповіді на це — стратегія для вашої команди неправильна.

The judgement this question is really asking for: a strategy is a function of deploy frequency and how many versions are alive in production. Not of team size, and not of the tech lead's taste. Deploying daily with one version — GitHub Flow. Releasing on a schedule and needing a stabilisation window — Git Flow or release branches. Deploying continuously with strong automation — trunk-based. And there's a QA criterion that rarely gets said out loud: where exactly the regression runs, and against which state. If the strategy has no obvious answer to that, it's the wrong strategy for your team.

Схема — три форми історії й що в кожній тестується
Diagram — three shapes of history, and what gets tested in each
TRUNK-BASED
  ──●──●──●──●──●──●──●──   main   регресія: постійно по main
     \_/  \_/               гілки на години; ризик — у флагах

GITHUB FLOW
  ──●─────────●─────────●   main   регресія: у PR, до мержу
     \       / \       /             ворота = required checks
      ●──●──●   ●──●──●

GIT FLOW
  ──●───────────────●────   main    (теги релізів)
     \             /
      ●──●──●──●──●          release/1.4  ← вікно стабілізації,
     /    /    /                          тут живе ручна регресія
  ──●────●────●────────     develop
TRUNK-BASED
  ──●──●──●──●──●──●──●──   main   regression: continuously on main
     \_/  \_/               branches live for hours; risk sits in flags

GITHUB FLOW
  ──●─────────●─────────●   main   regression: in the PR, before merge
     \       / \       /             gate = required checks
      ●──●──●   ●──●──●

GIT FLOW
  ──●───────────────●────   main    (release tags)
     \             /
      ●──●──●──●──●          release/1.4  ← stabilisation window,
     /    /    /                          manual regression lives here
  ──●────●────●────────     develop
🔧 Що з цього використовуємо ми Наші репозиторії їдуть на GitHub Flow: одна задача — одна гілка <тип>/<issue>-<слаг> — один PR, нічого прямо в main. Стратегія тримається не домовленістю, а налаштуванням: branch protection на main плюс обов'язкові статуси перевірок. Домовленість забувають у п'ятницю ввечері, налаштування — ні.
🔧 What we use out of this Our repositories run on GitHub Flow: one task — one <type>/<issue>-<slug> branch — one PR, nothing straight into main. The strategy holds through configuration, not agreement: branch protection on main plus required check statuses. An agreement gets forgotten on a Friday evening; a setting doesn't.
🗣 Як читати вголос Я б відповідав не схемами, а тим, що кожна стратегія робить із тестуванням. Trunk-based — усі живуть у гілках на години, недороблене за фіче-флагами; для QA це означає, що поточний стан існує в одному місці й регресія постійно біжить по main, зате предметом тестування стають самі флаги і їхня комбінаторика, і ручну регресію перед кожним мержем туди фізично не вставиш. GitHub Flow — main завжди придатний до релізу, одна гілка на задачу, PR із перевірками; для QA це найзручніше, бо PR стає воротами якості: branch protection плюс обов'язкові checks — це момент, коли сьют перестає бути порадою й отримує право не пустити зміну. Git Flow із develop і release-гілками створювався для софту, що виходить партіями: він дає реальне вікно стабілізації, де можна пройти ручну регресію по замороженому стану, але платиш постійним подвійним мержем і довгою розбіжністю. Довгоживучі release-гілки неминучі, коли клієнти сидять на кількох версіях, і це найдорожче для QA, бо кожен фікс тестується стільки разів, скільки версій ви підтримуєте. А судження таке: стратегія — це функція від частоти деплою і кількості живих у проді версій, а не від розміру команди. І окремий QA-критерій: якщо у стратегії немає очевидної відповіді на питання, де біжить регресія і проти якого стану, то ця стратегія для команди неправильна.
🗣 How to say it out loud I'd answer this with what each strategy does to testing rather than with diagrams. Trunk-based means everyone lives in branches that last hours and unfinished work hides behind feature flags; for QA that means the current state exists in one place and the regression runs against main all the time, but the flags themselves become the thing under test, and you physically can't fit a manual pass in before every merge. GitHub Flow keeps main releasable, one branch per task, PRs with checks — for QA that's the most comfortable one, because the PR turns into the quality gate: branch protection plus required checks is the point where the suite stops being advice and gets the right to block a change. Git Flow with develop and release branches was built for software that ships in batches, and it does give a real stabilisation window where you can walk a manual regression over a frozen state — you just pay for it with a permanent double merge and long divergence. Long-lived release branches are unavoidable when customers sit on several versions, and that's the most expensive one for QA, because every fix gets tested as many times as you have supported versions. My judgement is that the strategy is a function of deploy frequency and how many versions are live in production, not of team size. And the QA criterion I'd add on top: if a strategy has no obvious answer to where the regression runs and against what state, it's the wrong strategy for that team.
B6 Merge чи rebase — чесний компроміс, а не релігіяMerge or rebase — an honest trade-off, not a religion
ГілкиBranches Senior ▶

Обидві команди розв'язують одну задачу — «моя гілка відстала від main» — і платять за це різною валютою. Відповідь «rebase кращий» або «merge безпечніший» на співбесіді чути одразу: це завчена сторона, а не розуміння ціни.

Both commands solve one problem — "my branch has fallen behind main" — and they pay for it in different currency. An answer like "rebase is better" or "merge is safer" is audible immediately in an interview: it's a memorised side, not an understanding of the price.

git merge main у свою гілку

git merge main into your own branch

Створює мерж-коміт із двома батьками. Історія зберігає правдиву форму: тут дві лінії справді існували паралельно й зустрілись отут. Нічого не переписується, хеші стоять на місці, всі, хто вже забрав твою гілку, нічого не помічають. Ціна — граф заростає мерж-комітами, і на активному репозиторії git log --graph перетворюється на плетиво; плюс конфлікт розв'язується один раз усередині мерж-коміту, що зручно, але ховає, який саме твій коміт зіткнувся з чужим.

Creates a merge commit with two parents. History keeps its truthful shape: two lines really did run in parallel here, and they met right there. Nothing is rewritten, the hashes stay put, and everyone who has already pulled your branch notices nothing at all. The price is that the graph gets overgrown with merge commits, and on an active repository git log --graph turns into wickerwork; on top of that the conflict is resolved once inside the merge commit, which is convenient but hides which of your commits actually collided with which of theirs.

git rebase main

git rebase main

Твої коміти по одному програються поверх нового main. Історія лінійна, git log читається як розповідь, git bisect іде прямою. Ціна серйозніша, ніж здається: кожен програний коміт отримує новий хеш — це копія, оригінал із гілки зникає. І конфлікт може повторитись покомітно: якщо три твої коміти чіпають той самий рядок, ти розв'яжеш те саме зіткнення три рази.

Your commits are replayed one by one on top of the new main. History is linear, git log reads like a story, git bisect walks a straight line. The price is steeper than it looks: every replayed commit gets a new hash — it's a copy, and the original disappears from the branch. And the conflict can repeat commit by commit: if three of your commits touch the same line, you'll resolve the same collision three times.

Чесний поділ

The honest division

  • Rebase — те, що тільки твоє і ще не роздане. Ідеально причесати локальну гілку перед відкриттям PR.
  • Merge — те, що вже є в інших. Історію, яку хтось забрав, не переписують заради красивішого графа.
  • Що робиться з main — рішення команди, а не твоє, і воно тримається налаштуванням кнопки мержу, а не дисципліною.
  • Rebase what is only yours and hasn't been handed out yet. Perfect for tidying a local branch before opening a PR.
  • Merge into what other people already have. You don't rewrite history somebody has pulled for the sake of a prettier graph.
  • What happens to main is the team's decision, not yours, and it holds through the merge button's configuration rather than through discipline.

QA-аргумент, який справді важить: лінійна історія коштує реальних грошей на git bisect і на питанні «з якого білду поплив цей флейк». Це чесний аргумент за rebase — але саме за rebase своєї гілки перед мержем, який не коштує нікому нічого.

The QA argument that genuinely carries weight: a linear history saves real money on git bisect and on the question "which build did this flake start from". That's an honest argument for rebase — but specifically for rebasing your own branch before the merge, which costs nobody anything.

Схема — та сама вихідна ситуація, два результати
Diagram — the same starting point, two outcomes
БУЛО:
        A ─ B ─ C ─ D ─ E              main
             \
              t1 ─ t2 ─ t3            test/431

git merge main  (у test/431):
        A ─ B ─ C ─ D ─ E              main
             \           \
              t1 ─ t2 ─ t3 ─ M        test/431
  M — мерж-коміт, два батьки. Хеші t1..t3 не змінились.
  Історія правдива; граф заростає.

git rebase main  (у test/431):
        A ─ B ─ C ─ D ─ E              main
                         \
                          t1' ─ t2' ─ t3'   test/431
  t1' t2' t3' — НОВІ коміти з новими хешами.
  t1 t2 t3 більше не в гілці (лишились у reflog).
  Історія лінійна; вона неправда, зате читається.
BEFORE:
        A ─ B ─ C ─ D ─ E              main
             \
              t1 ─ t2 ─ t3            test/431

git merge main  (on test/431):
        A ─ B ─ C ─ D ─ E              main
             \           \
              t1 ─ t2 ─ t3 ─ M        test/431
  M — a merge commit, two parents. Hashes of t1..t3 are unchanged.
  History is truthful; the graph gets overgrown.

git rebase main  (on test/431):
        A ─ B ─ C ─ D ─ E              main
                         \
                          t1' ─ t2' ─ t3'   test/431
  t1' t2' t3' — NEW commits with new hashes.
  t1 t2 t3 are no longer on the branch (they remain in the reflog).
  History is linear; it is not true, but it reads well.
Git — як це виглядає в роботі
Git — what it looks like in practice
// гілка ще нікому не віддана — причісуємо перед PR
git fetch origin
git rebase origin/main
git push --force-with-lease      // перший і єдиний force тут

// гілку вже хтось забрав — тільки merge
git fetch origin
git merge origin/main
git push                          // без force, нікого не зачепили

// щоб `git pull` не ліпив мерж-коміти на рівному місці:
git config --global pull.rebase true
// the branch has not been handed to anyone yet — tidy it up before the PR
git fetch origin
git rebase origin/main
git push --force-with-lease      // the first and only force here

// someone has already pulled the branch — merge only
git fetch origin
git merge origin/main
git push                          // no force, nobody is affected

// so that `git pull` does not create merge commits out of nowhere:
git config --global pull.rebase true
🔧 Git-патерни в цьому прикладі git rebase origin/main проти git merge origin/main — та сама мета, різна ціна; --force-with-lease замість --force після rebase; pull.rebase true — щоб рутинний pull не породжував мерж-коміти «Merge branch main into main»; git rebase -i — той самий механізм, але з можливістю склеїти й перейменувати свої коміти перед PR.
🔧 Git patterns in this example git rebase origin/main versus git merge origin/main — the same goal, a different price; --force-with-lease instead of --force after a rebase; pull.rebase true — so that a routine pull doesn't breed "Merge branch main into main" commits; git rebase -i — the same machinery, but with the option to squash and rename your own commits before the PR.
🗣 Як читати вголос Merge і rebase розв'язують одну задачу — моя гілка відстала від main — і платять за це різною валютою, тому відповідь «rebase кращий» я вважаю завченою стороною, а не розумінням. Merge створює мерж-коміт із двома батьками: історія зберігає правдиву форму, нічого не переписується, хеші стоять на місці, і всі, хто вже забрав мою гілку, нічого не помічають — а ціна в тому, що граф заростає мерж-комітами і стає нечитабельним. Rebase програє мої коміти по одному поверх нового main: історія лінійна, log читається як розповідь, bisect іде прямою — але кожен програний коміт отримує новий хеш, тобто це копія, а оригінал із гілки зникає, і конфлікт може повторитись покомітно, якщо кілька моїх комітів чіпають той самий рядок. Звідси чесний поділ: rebase роблю тому, що тільки моє і ще не роздане, merge — тому, що вже є в інших, бо історію, яку хтось забрав, не переписують заради красивішого графа. А те, що робиться з main, — це рішення команди, і воно має триматись налаштуванням кнопки мержу, а не дисципліною. Ну і суто QA-аргумент: лінійна історія реально економить час на bisect і на пошуку білду, з якого поплив флейк, — але це аргумент за rebase власної гілки перед PR, який не коштує нікому нічого.
🗣 How to say it out loud Merge and rebase solve one problem — my branch has fallen behind main — and they pay for it in different currency, so "rebase is better" sounds to me like a memorised side rather than understanding. Merge creates a merge commit with two parents: history keeps its truthful shape, nothing is rewritten, the hashes stay put, and anyone who already pulled my branch notices nothing — the price is that the graph gets overgrown with merge commits and stops being readable. Rebase replays my commits one by one on top of the new main: history is linear, the log reads like a story, bisect walks a straight line — but every replayed commit gets a new hash, so it's a copy and the original leaves the branch, and the conflict can repeat commit by commit if several of my commits touch the same line. So the honest split is that I rebase what's only mine and hasn't been handed out, and I merge into what other people already have, because you don't rewrite history somebody pulled just to get a prettier graph. What happens to main is the team's decision, and it should hold through the merge-button setting rather than through discipline. And the purely QA argument: a linear history genuinely saves time on bisect and on finding the build a flake started from — but that's an argument for rebasing my own branch before the PR, which costs nobody anything.
B7 Чому rebase спільної гілки б'є по інших людях — і що робити, якщо вже?Why rebasing a shared branch hurts other people — and what to do if you already have
ГілкиBranches Senior ▶

Одна фраза, з якої все випливає: rebase не переміщує коміти, він їх копіює. Змінився батько — змінився хеш. Виходить інший об'єкт із тим самим діффом і тим самим повідомленням, а старий просто перестає бути частиною гілки.

One sentence everything follows from: rebase doesn't move commits, it copies them. The parent changed, so the hash changed. What comes out is a different object with the same diff and the same message, and the old one simply stops being part of the branch.

Механіка шкоди, покроково

The mechanics of the damage, step by step

  • Ви з колегою обоє маєте feat/checkout на коміті abc.
  • Ти робиш rebase на main. Локально гілка тепер закінчується на abc' — це інший коміт.
  • На сервері досі abc. Звичайний git push відхиляється як non-fast-forward — Git тут не заважає, він попереджає. Інцидент влаштовує той, хто «розв'язує» це через --force.
  • Після force push у колеги git pull бачить сервер, у якому більше немає його основи. Якщо в нього pull налаштований на merge — він отримує гілку з обома лініями: кожен коміт продубльовано, один раз як abc, другий як abc', і конфлікт на кожну пару. Якщо він «полагодить» це власним force-пушем — зникає вже твій rebase разом із усім, що ти запушив після нього.
  • You and your colleague both have feat/checkout at commit abc.
  • You rebase onto main. Locally the branch now ends at abc' — a different commit.
  • The server still has abc. An ordinary git push is rejected as non-fast-forward — Git isn't getting in the way here, it's warning you. The incident is created by whoever "resolves" that with --force.
  • After the force push, your colleague's git pull sees a server that no longer contains their base. If their pull is set to merge, they get a branch with both lines: every commit duplicated, once as abc and once as abc', and a conflict for every pair. If they "fix" that with a force push of their own, your rebase disappears along with everything you pushed after it.

Що робити замість: не переписувати те, що роздано. Якщо гілка вже на сервері й хтось міг її забрати — вливай main мержем. Rebase роби до першого пуша, а не після.

What to do instead: don't rewrite what has been handed out. If the branch is already on the server and somebody could have pulled it, bring main in with a merge. Rebase before the first push, not after.

Якщо переписати таки треба (у гілку заїхав секрет або файл на 300 МБ) — спершу попередити людей, а тоді --force-with-lease, ніколи не голий --force. --force-with-lease відмовляється пушити, якщо віддалена гілка зсунулась після твого останнього fetch: це перетворює «знищую те, що там є» на «знищую рівно той стан, який я бачив». Гарантією це не є — фоновий fetch від IDE освіжає твою «оренду» і послаблює перевірку — але типовий випадок вона ловить.

If you really do have to rewrite (a secret got into the branch, or a 300 MB file) — warn people first, then use --force-with-lease, never a bare --force. --force-with-lease refuses to push if the remote branch has moved since your last fetch: it turns "I'm destroying whatever is there" into "I'm destroying exactly the state I saw". It isn't a guarantee — a background fetch from your IDE refreshes the lease and weakens the check — but it catches the typical case.

Рятівна фраза для співбесіди й для п'ятниці: нічого по-справжньому не зникло. Коміти живуть в об'єктній базі ще близько 90 днів, і git reflog тримає всі позиції, де стояв HEAD. Але reflog локальний — він на машині того, хто ці коміти мав, а не на сервері. Тому перше, що робиш після інциденту, — знаходиш людину, у якої стара версія була, і не даєш їй нічого «почистити».

The line that saves you in an interview and on a Friday: nothing has genuinely disappeared. Commits live in the object store for about 90 more days, and git reflog holds every position HEAD has stood at. But the reflog is local — it's on the machine of whoever had those commits, not on the server. So the first thing you do after the incident is find the person who had the old version and stop them from "cleaning anything up".

Схема — що бачить колега після force push
Diagram — what your colleague sees after a force push
У ТЕБЕ ПІСЛЯ REBASE:
   main ─── abc' ─── def'          feat/checkout (локально)

НА СЕРВЕРІ (ще):
   base ─── abc  ─── def           origin/feat/checkout

git push        → ! [rejected] non-fast-forward   ← попередження
git push --force → сервер тепер має abc' def'

У КОЛЕГИ ПІСЛЯ ЙОГО git pull (з merge):
   base ─── abc  ─── def ───┐
                             M     feat/checkout  ← кожна зміна двічі
   main ─── abc' ─── def' ──┘        і конфлікт на кожну пару
ON YOUR MACHINE AFTER REBASE:
   main ─── abc' ─── def'          feat/checkout (local)

ON THE SERVER (still):
   base ─── abc  ─── def           origin/feat/checkout

git push        → ! [rejected] non-fast-forward   ← warning
git push --force → the server now has abc' def'

ON A TEAMMATE'S MACHINE AFTER THEIR git pull (with merge):
   base ─── abc  ─── def ───┐
                             M     feat/checkout  ← every change twice
   main ─── abc' ─── def' ──┘        and a conflict for every pair
Git — порятунок через reflog
Git — the rescue through reflog
git reflog
// 7c1e0aa (HEAD -> feat/checkout) HEAD@{0}: rebase finished
// 3b9d412                        HEAD@{1}: rebase start
// 5e2f8c1                        HEAD@{2}: commit: fix flaky wait  ← ось воно

// безпечно: спершу подивитись, не рухаючи гілку
git show 5e2f8c1

// повісити рятувальну гілку на старий стан — нічого не ламає
git branch rescue/checkout-before-rebase 5e2f8c1

// або відкотити поточну гілку туди ж (це вже руйнівно)
git reset --hard 5e2f8c1
git reflog
// 7c1e0aa (HEAD -> feat/checkout) HEAD@{0}: rebase finished
// 3b9d412                        HEAD@{1}: rebase start
// 5e2f8c1                        HEAD@{2}: commit: fix flaky wait  ← there it is

// safe: look first, without moving the branch
git show 5e2f8c1

// hang a rescue branch on the old state — breaks nothing
git branch rescue/checkout-before-rebase 5e2f8c1

// or roll the current branch back there (this one is destructive)
git reset --hard 5e2f8c1
🔧 Git-патерни в цьому прикладі --force-with-lease замість --force — відмовляється, якщо віддалена гілка зсунулась після твого fetch; git reflog — локальний журнал усіх позицій HEAD, живе ~90 днів; git branch <назва> <хеш> — недеструктивний спосіб врятувати стан (на відміну від reset --hard); на рівні репозиторію те саме питання закривається branch protection із забороною force push у main.
🔧 Git patterns in this example --force-with-lease instead of --force — it refuses if the remote branch moved after your fetch; git reflog — the local journal of every HEAD position, kept for around 90 days; git branch <name> <hash> — a non-destructive way to rescue a state (unlike reset --hard); at repository level the same question is closed by branch protection with force push into main forbidden.
🗣 Як читати вголос Усе випливає з однієї фрази: rebase не переміщує коміти, він їх копіює. Змінився батько — змінився хеш, тобто виходить інший об'єкт із тим самим діффом, а старий просто перестає бути частиною гілки. Далі механіка шкоди така: ми з колегою обоє маємо гілку на коміті abc, я роблю rebase, локально в мене вже abc штрих, а на сервері досі abc. Звичайний push відхиляється як non-fast-forward — і це Git не заважає, а попереджає; інцидент влаштовує той, хто розв'язує це через force. Після force-пуша колега робить pull і бачить сервер, у якому більше немає його основи: якщо в нього pull через merge, він отримує гілку з обома лініями, кожен коміт продубльовано і конфлікт на кожну пару, а якщо він полагодить це власним force-пушем, зникне вже мій rebase. Тому правило просте: те, що роздано, не переписують — у таку гілку main вливають мержем, а rebase роблять до першого пуша. Якщо переписати таки треба, наприклад у гілку заїхав секрет, то спершу попереджаю людей і використовую force-with-lease, а не голий force: він відмовиться пушити, якщо віддалена гілка зсунулась після мого fetch. І головне, що варто пам'ятати: нічого по-справжньому не зникло — коміти живуть в об'єктній базі близько дев'яноста днів, і reflog тримає всі позиції HEAD. Тільки reflog локальний, він на машині того, хто ці коміти мав, а не на сервері.
🗣 How to say it out loud It all follows from one sentence: rebase doesn't move commits, it copies them. The parent changed, so the hash changed, which means a different object with the same diff, and the old one simply stops being part of the branch. The damage goes like this: my colleague and I both have the branch at commit abc, I rebase, locally I'm now at abc-prime, and the server still has abc. A normal push gets rejected as non-fast-forward — that's Git warning me, not blocking me, and the incident is created by whoever resolves it with force. After that force push my colleague pulls and sees a server that no longer holds their base: if their pull merges, they get both lines, every commit duplicated and a conflict on every pair; and if they fix that with their own force push, my rebase is the thing that disappears. So the rule is that you don't rewrite what's been handed out — into a branch like that you bring main with a merge, and you rebase before the first push. If I really do have to rewrite, say a secret got in, I warn people first and use force-with-lease rather than bare force, because it refuses if the remote moved after my fetch. And the thing worth remembering is that nothing has really vanished — commits sit in the object store for about ninety days and the reflog holds every HEAD position. The catch is that the reflog is local, so it's on the machine of whoever had those commits, not on the server.
▶ 3 8
GH1 Життя pull request — від гілки до мержу. Що відбувається на кожному кроці?The life of a pull request — from branch to merge. What happens at each step?
GitHubJunior▶

Перше, що варто сказати вголос: pull request — це не фіча Git. Git знає тільки про коміти, гілки й віддалені репозиторії. PR вигадав GitHub — це запис у їхній базі даних, який каже: «ось моя гілка, візьміть її зміни в main, ось обговорення, ось результати перевірок».

The first thing worth saying out loud: a pull request isn't a Git feature. Git only knows about commits, branches and remote repositories. GitHub invented the PR — it's a record in their database that says "here's my branch, take its changes into main, here's the discussion, here are the check results".

Сім кроків, які реально відбуваються:

The seven steps that actually happen:

  • Гілка від свіжого main — не від того, що лежить локально три дні. Спочатку git pull, потім git switch -c.
  • Коміти — маленькі й осмислені. Це те, що читатиме рев'юер, а потім і ти сам через півроку.
  • Push — гілка з'являється на GitHub. PR ще нема, є тільки гілка.
  • Відкрити PR — обираєш base (куди вливаємо) і compare (звідки). Якщо робота ще не готова — Draft, він не проситиме рев'ю і в багатьох налаштуваннях не запускає повний CI.
  • CI — GitHub Actions ловить подію pull_request і запускає перевірки. Їхній результат прилипає до PR.
  • Рев'ю — Approve, Comment або Request changes. Кожен новий push оновлює PR: діф перераховується, CI перезапускається.
  • Merge + видалити гілку — гілку після мержу тримати нема сенсу, коміти вже в main.
  • A branch off a fresh main — not off whatever has been sitting on your disk for three days. git pull first, then git switch -c.
  • Commits — small and meaningful. This is what the reviewer will read, and what you'll read yourself six months from now.
  • Push — the branch appears on GitHub. There's no PR yet, only a branch.
  • Open the PR — you pick the base (where it goes) and the compare (where it comes from). If the work isn't ready, open it as a Draft: it won't ask for review, and in many configurations it doesn't start the full CI.
  • CI — GitHub Actions catches the pull_request event and runs the checks. Their result sticks to the PR.
  • Review — Approve, Comment or Request changes. Every new push updates the PR: the diff is recomputed, CI runs again.
  • Merge and delete the branch — there's no point keeping a branch after the merge, its commits are already in main.

Головне непорозуміння новачків: PR — це не знімок коду в момент відкриття. Це живий об'єкт, прив'язаний до гілки. Запушив ще один коміт — PR сам оновився, ніде нічого перевідкривати не треба.

The main beginner misunderstanding: a PR isn't a snapshot of the code at the moment you opened it. It's a live object attached to a branch. Push one more commit and the PR updates itself; there's nothing to reopen anywhere.

І другий момент: діф у PR рахується не «моя гілка проти поточного main», а від точки, де вони розійшлися — від merge-base. Тому чужі коміти, що з'явились у main після твого відгалуження, у твоєму діфі не світяться. Це зручно для читання й водночас саме те, через що два зелені PR можуть разом зламати main (окрема картка нижче).

And a second point: the diff in a PR isn't "my branch against the current main", it's computed from the point where the two diverged — from the merge-base. So other people's commits that landed in main after you branched never show up in your diff. That's convenient to read, and it's also exactly why two green PRs can break main between them (there's a separate card on that below).

Дрібниця, яка економить хвилини щодня: рядок Closes #123 в описі PR закриє issue автоматично в момент мержу. Не «пов'яже», а саме закриє. Це працює тільки для base-гілки за замовчуванням і тільки з опису PR або тексту коміта.

A small thing that saves minutes every day: the line Closes #123 in a PR description closes the issue automatically at the moment of the merge. Not "links" it — closes it. It only works for the default base branch, and only from the PR description or a commit message.

Той самий шлях у командах
The same path in commands
# 1. свіжий main — не той, що лежав локально з понеділка
git switch main
git pull

# 2. гілка за конвенцією репо: тип/номер-issue-слаг
git switch -c test/128-checkout-guest-flow

# 3-4. коміти і push; -u прив'яже локальну гілку до віддаленої
git add tests/checkout/guest.spec.ts
git commit -m "test(checkout): cover guest checkout happy path"
git push -u origin test/128-checkout-guest-flow

# 5. PR з консолі — швидше, ніж шукати кнопку в браузері
gh pr create --base main --title "test(checkout): guest checkout" \
             --body  "Closes #128" --draft

# готовий до рев'ю — знімаємо draft
gh pr ready

# 7. після мержу прибрати за собою
git switch main && git pull
git branch -d test/128-checkout-guest-flow
# 1. a fresh main — not the one that has been sitting locally since Monday
git switch main
git pull

# 2. branch named by repo convention: type/issue-number-slug
git switch -c test/128-checkout-guest-flow

# 3-4. commits and push; -u links the local branch to the remote one
git add tests/checkout/guest.spec.ts
git commit -m "test(checkout): cover guest checkout happy path"
git push -u origin test/128-checkout-guest-flow

# 5. PR from the console — faster than hunting for the button in the browser
gh pr create --base main --title "test(checkout): guest checkout" \
             --body  "Closes #128" --draft

# ready for review — drop the draft status
gh pr ready

# 7. after the merge, clean up after yourself
git switch main && git pull
git branch -d test/128-checkout-guest-flow
Чому діф показує не те, що ти думаєш
Why the diff shows something other than what you think
main      A───B───C───D───E      ← D і E з'явились, поки ти працював
               \
твоя гілка      F───G───H        ← твої коміти

           ▲
      merge-base = B

діф у PR  =  B → H   (тільки F, G, H)
НЕ          E → H

Наслідок: D і E ти в PR не бачиш — і CI перевіряє
твою гілку на базі B, а не того main, у який вона потрапить.
main      A───B───C───D───E      ← D and E appeared while you were working
               \
your branch     F───G───H        ← your commits

           ▲
      merge-base = B

PR diff   =  B → H   (only F, G, H)
NOT          E → H

Consequence: you do not see D and E in the PR — and CI checks
your branch on top of B, not the main it will land in.
🔧 Git / GitHub — що вжито в прикладі git switch -c — сучасна заміна checkout -b саме для гілок; push -u — задає upstream, після нього достатньо голого git push; gh — офіційний CLI GitHub, робить те саме, що кнопки в браузері; Closes #N — ключове слово автозакриття issue; merge-base — спільний предок двох гілок, від нього рахується діф.
🔧 Git / GitHub — what's used in this example git switch -c — the modern replacement for checkout -b when branches are what you mean; push -u — sets the upstream, after which a bare git push is enough; gh — GitHub's official CLI, it does the same things the buttons in the browser do; Closes #N — the keyword that auto-closes an issue; merge-base — the common ancestor of two branches, the point the diff is measured from.
🗣 Як читати вголос Pull request — це механізм GitHub, а не Git: сам Git знає тільки коміти й гілки, а PR це запис на стороні GitHub, який каже «візьміть мою гілку в main» і збирає навколо неї обговорення та результати перевірок. Шлях завжди однаковий: беру свіжий main, відгалужуюсь від нього, роблю кілька невеликих осмислених комітів, пушу гілку і відкриваю PR, вказавши base і compare. Поки робота не готова, тримаю його як draft, щоб не смикати рев'юерів і не палити хвилини CI. Далі на подію pull_request запускаються перевірки, приходить рев'ю, і на кожен новий push PR оновлюється сам — це живий об'єкт, прив'язаний до гілки, а не знімок коду. Важливо розуміти, що діф рахується від merge-base, тобто від точки розходження, а не від поточного стану main — тому чужих комітів у своєму діфі я не бачу. І дрібниця, яку варто робити завжди: рядок Closes і номер в описі PR закриває issue автоматично в момент мержу, а гілку після мержу видаляю, бо її коміти вже в main.
🗣 How to say it out loud A pull request is a GitHub mechanism, not a Git one: Git itself only knows commits and branches, and a PR is a record on GitHub's side saying "take my branch into main", with the discussion and the check results collected around it. The path is always the same: take a fresh main, branch off it, make a few small meaningful commits, push the branch and open the PR with a base and a compare. While the work isn't ready I keep it as a draft, so I'm not pinging reviewers or burning CI minutes. Then the checks fire on the pull_request event, review comes in, and every new push updates the PR by itself — it's a live object attached to a branch, not a snapshot of the code. It's worth knowing that the diff is measured from the merge-base, the point where the branches diverged, not from the current state of main, which is why other people's commits never appear in my diff. And one small habit: a Closes line with the issue number in the PR description closes that issue automatically on merge, and I delete the branch afterwards, because its commits are already in main.
GH2 Conventional Commits — що це за формат і що з нього має тест-репозиторій?Conventional Commits — what the format is, and what a test repository gets out of it
GitHubJunior▶

Conventional Commits — це домовленість про перший рядок коміта: тип(область): що зроблено. Все. Ніякого інструмента ставити не треба, це просто угода, яку команда тримає.

Conventional Commits is an agreement about a commit's first line: type(scope): what was done. That's it. There's no tool to install, it's just a convention the team keeps.

Типи, які реально трапляються в тест-репозиторії:

The types that actually turn up in a test repository:

  • test: — новий тест або зміна наявного. Найчастіший тип у нас.
  • fix: — виправлення поламаного: page object, хелпер, фікстура, сам фреймворк.
  • feat: — нова можливість тестового фреймворку: свій репортер, нова фікстура, кастомний матчер.
  • ci: — зміни в .github/workflows/, кеші, матриці, артефакти.
  • chore: — оновлення залежностей, конфіги, дрібне прибирання.
  • refactor: — код переписано, поведінка та сама.
  • docs: — README, коментарі, документація.
  • test: — a new test, or a change to an existing one. The most common type for us.
  • fix: — fixing something broken: a page object, a helper, a fixture, the framework itself.
  • feat: — a new capability of the test framework: your own reporter, a new fixture, a custom matcher.
  • ci: — changes in .github/workflows/, caches, matrices, artifacts.
  • chore: — dependency bumps, configs, small clean-ups.
  • refactor: — the code was rewritten, the behaviour is the same.
  • docs: — README, comments, documentation.

Навіщо це, якщо ніхто не генерує чейнджлоги. Формальна відповідь — автоматичні релізні нотатки й semantic-release, який з fix: робить патч-версію, з feat: — мінорну. Але в тест-репозиторії головна користь буденніша: git log стає таблицею, у якій можна шукати.

Why bother if nobody generates changelogs. The formal answer is automatic release notes and semantic-release, which turns fix: into a patch version and feat: into a minor one. But in a test repository the main benefit is more everyday: git log becomes a table you can search.

Коли тест раптом починає флейкати, перше питання — «що з ним робили останнім часом». З конвенцією це один git log --oneline --grep. Без неї це читання сорока рядків на кшталт «fix», «fix again», «wip», «norm».

When a test suddenly starts flaking, the first question is "what has been done to it lately". With the convention that's one git log --oneline --grep. Without it, it's reading forty lines of "fix", "fix again", "wip" and "ok now".

Область (scope) — необов'язкова, але саме вона робить лог придатним для пошуку. Бери її з домену застосунку, який тестуєш (checkout, auth, search), а не з шляху до файлу. Шляхи змінюються, домен — ні.

The scope is optional, but it's the part that makes the log searchable. Take it from the domain of the application you're testing (checkout, auth, search), not from the file path. Paths change; the domain doesn't.

Найчастіша плутанина на співбесіді: «полагодив тест» — це test: чи fix:? Робочий поділ: якщо зламався тест, а застосунок здоровий — це test:. Якщо зламався інструмент, яким тестуємо (фікстура, page object, хелпер) — це fix:. І головне — команда домовляється один раз і потім не сперечається на кожному PR.

The most common confusion in interviews: "I fixed a test" — is that test: or fix:? A workable split: if the test broke while the application is healthy, it's test:. If the tooling you test with broke — a fixture, a page object, a helper — it's fix:. And the main thing is that the team agrees once and then stops arguing about it on every PR.

Формат — і що виходить у логу
The format — and what comes out in the log
# повна форма
<тип>(<область>)!: <короткий опис у наказовому способі>
                 ▲
                 └── «!» = ламає сумісність

<тіло: навіщо, а не що — «що» видно з діфа>

<футер: Closes #128 / BREAKING CHANGE: ...>

# ❌ так виглядає лог без конвенції
a1b2c3d fix
e4f5g6h fix again
i7j8k9l wip
m0n1o2p норм тепер

# ✅ так — з конвенцією
a1b2c3d test(checkout): add guest checkout happy path
e4f5g6h fix(auth): wait for token refresh before assert
i7j8k9l ci(actions): cache browsers by playwright version
m0n1o2p refactor(pages): split ProductPage into fragments
# full form
<type>(<scope>)!: <short description in the imperative mood>
                 ▲
                 └── "!" = breaking change

<body: why, not what — the "what" is visible in the diff>

<footer: Closes #128 / BREAKING CHANGE: ...>

# ❌ this is what a log without a convention looks like
a1b2c3d fix
e4f5g6h fix again
i7j8k9l wip
m0n1o2p ok now

# ✅ like this — with a convention
a1b2c3d test(checkout): add guest checkout happy path
e4f5g6h fix(auth): wait for token refresh before assert
i7j8k9l ci(actions): cache browsers by playwright version
m0n1o2p refactor(pages): split ProductPage into fragments
Заради чого це все — пошук по історії
What it's all for — searching history
# усі виправлення в auth за останній місяць
git log --oneline --grep='^fix(auth)' --since='1 month ago'

# що взагалі робили з чекаутом — і хто
git log --oneline --grep='(checkout)' --format='%h %an %s'

# повний коміт з тілом і футером
git commit -m "fix(auth): wait for token refresh before asserting profile" \
           -m "Тест падав раз на десять прогонів: асерт стріляв між
відповіддю на login і оновленням хедера. Чекаємо на відповідь /refresh." \
           -m "Closes #204"
# all auth fixes over the last month
git log --oneline --grep='^fix(auth)' --since='1 month ago'

# what was done to checkout at all — and by whom
git log --oneline --grep='(checkout)' --format='%h %an %s'

# a full commit with body and footer
git commit -m "fix(auth): wait for token refresh before asserting profile" \
           -m "The test failed once in ten runs: the assert fired between
the login response and the header refresh. Wait for the /refresh response." \
           -m "Closes #204"
🔧 Git / GitHub — що вжито в прикладі git log --grep — пошук регуляркою по тексту коміта (не по коду; для коду це -S); ^fix(auth) — якір початку рядка, щоб не ловити згадки в тілі; --format='%h %an %s' — короткий хеш, автор, тема; кілька -m поспіль дають заголовок, тіло і футер окремими абзацами.
🔧 Git / GitHub — what's used in this example git log --grep — a regex search over a commit's message (not its code; for code that's -S); ^fix(auth) — an anchor at the start of the line, so you don't catch mentions in the body; --format='%h %an %s' — short hash, author, subject; several -m flags in a row give you the subject, the body and the footer as separate paragraphs.
🗣 Як читати вголос Conventional Commits — це домовленість про перший рядок коміта у форматі тип, область у дужках, двокрапка і короткий опис. У тест-репозиторії реально живуть кілька типів: test для самих тестів, fix для поламаних фікстур і page object'ів, feat для нових можливостей фреймворку, ci для воркфлоу, chore для залежностей. Формально це потрібно для автоматичних чейнджлогів і semantic-release, де fix дає патч-версію, а feat мінорну. Але щоденна користь інша: git log перетворюється на таблицю, по якій можна шукати — коли тест починає флейкати, я одним grep по логу бачу все, що робили з цією областю за місяць, замість того щоб читати сорок рядків «fix», «fix again» і «wip». Область беру з домену застосунку, а не зі шляху до файлу, бо шляхи змінюються, а домен ні. І щодо вічної суперечки «полагодив тест — це test чи fix»: якщо зламався тест, а застосунок здоровий, це test, якщо зламався інструмент, яким тестуємо, це fix — головне домовитися один раз і не сперечатися на кожному PR.
🗣 How to say it out loud Conventional Commits is an agreement about a commit's first line: a type, a scope in brackets, a colon and a short description. In a test repository only a handful of types really live: test for the tests themselves, fix for broken fixtures and page objects, feat for new framework capabilities, ci for workflows, chore for dependencies. Formally it exists for automated changelogs and semantic-release, where fix gives a patch version and feat a minor one. But the day-to-day benefit is different: git log turns into a table I can search, so when a test starts flaking I grep the log and see everything that touched that area over the last month, instead of reading forty lines of "fix", "fix again" and "wip". I take the scope from the application's domain rather than from the file path, because paths change and the domain doesn't. And on the eternal argument about whether fixing a test is test or fix — if the test broke while the app is healthy it's test, and if the tooling we test with broke it's fix; the important part is agreeing once and not relitigating it on every PR.
GH3 Як забрати чужий PR до себе локально — включно з PR із форку?How to pull somebody else's PR down locally — including a PR from a fork
GitHubMiddle▶

Для QA це не факультатив, а основний спосіб рев'ю. Діф у браузері показує, що людина написала. Він не показує, чи тест зелений, чи він проходить десять разів поспіль, скільки він виконується і чи не тягне за собою чужі спеки. Це видно тільки коли запустиш.

For QA this isn't optional, it's the main way to review. A diff in the browser shows what the person wrote. It doesn't show whether the test is green, whether it passes ten times in a row, how long it takes, or whether it drags other people's specs down with it. You only see that by running it.

Найлінивіший спосіб — один рядок:

The laziest way — one line:

gh pr checkout 42 — і ти на гілці автора. Працює однаково і для гілки в тому ж репозиторії, і для форку. Це те, що варто називати першим.

gh pr checkout 42 — and you're on the author's branch. It works the same way for a branch in the same repository and for a fork. That's the one to name first.

Що під цим лежить — і чому це працює для форків:

What sits underneath — and why it works for forks:

GitHub на кожен PR тримає у твоєму репозиторії службові рефи, доступні тільки на читання: refs/pull/42/head — вершина гілки автора, і refs/pull/42/merge — попередній результат злиття цієї гілки з base. Саме тому форк не треба додавати як remote: GitHub уже поклав його коміти до тебе.

For every PR, GitHub keeps read-only service refs inside your repository: refs/pull/42/head — the tip of the author's branch, and refs/pull/42/merge — a preview of that branch merged with the base. That's exactly why you don't have to add the fork as a remote: GitHub has already put its commits into your copy.

Різниця між /head і /merge — те, що питають на співбесіді. /head — це рівно те, що написав автор. /merge — це «main плюс його зміни», тобто саме те, що зазвичай ганяє CI на подію pull_request. Тому буває, що локально в тебе зелено, а CI червоний: ти дивишся на /head, а CI — на /merge. Реф /merge ще й зникає, якщо PR має конфлікти.

The difference between /head and /merge is the part interviewers ask about. /head is precisely what the author wrote. /merge is "main plus their changes", which is what CI usually runs on a pull_request event. That's why you sometimes get green locally and red in CI: you're looking at /head while CI is looking at /merge. The /merge ref also disappears when the PR has conflicts.

Обережно з правками в чужому PR. Запушити назад у гілку форку можна лише якщо автор лишив увімкненим «Allow edits by maintainers», і пушити треба саме в його репозиторій, а не в свій. Гілка, зроблена вручну з refs/pull/*, нікуди не трекається — голий git push у ній або впаде, або, гірше, піде не туди.

Be careful with edits inside somebody else's PR. You can only push back into a fork's branch if the author left "Allow edits by maintainers" on, and you have to push into their repository, not yours. A branch you made by hand out of refs/pull/* tracks nothing — a bare git push on it will either fail or, worse, go somewhere you didn't mean.

Три способи, від найлінивішого
Three ways, starting from the laziest
# 1. з gh CLI — сам розбереться з форком і upstream'ом
gh pr checkout 42

# 2. голий git: створити локальну гілку з рефа PR
git fetch origin pull/42/head:pr-42
git switch pr-42

# 3. подивитись рівно те, що бачив CI (main + зміни автора)
git fetch origin pull/42/merge
git switch --detach FETCH_HEAD

# власне рев'ю: не «запустив раз», а «запустив десять»
npm ci
npx playwright test tests/checkout --repeat-each=10 --workers=1

# що саме він змінив відносно бази
git diff main...pr-42

# прибрати за собою
git switch main
git branch -D pr-42
# 1. with gh CLI — it handles the fork and upstream by itself
gh pr checkout 42

# 2. plain git: create a local branch from the PR ref
git fetch origin pull/42/head:pr-42
git switch pr-42

# 3. look at exactly what CI saw (main + the author's changes)
git fetch origin pull/42/merge
git switch --detach FETCH_HEAD

# the actual review: not "ran it once" but "ran it ten times"
npm ci
npx playwright test tests/checkout --repeat-each=10 --workers=1

# what exactly they changed relative to the base
git diff main...pr-42

# clean up after yourself
git switch main
git branch -D pr-42
Де ці рефи живуть
Where those refs live
твій клон репозиторію
│
├── refs/heads/*              твої локальні гілки
├── refs/remotes/origin/*     звичайні гілки з origin
│
└── refs/pull/42/head         ← вершина гілки автора PR #42
    refs/pull/42/merge        ← попередній результат main + PR #42
        ▲                       (створює GitHub; зникає, якщо є конфлікт)
        │
        └── read-only. Пушити сюди не можна — тільки читати.

    PR із форку:  гілка живе у форку,
                  але refs/pull/42/head лежить у ТВОЄМУ репозиторії
                  → форк як remote додавати не треба

  локально зелено, а CI червоний?
  ти дивишся на /head, а CI майже завжди — на /merge
your repository clone
│
├── refs/heads/*              your local branches
├── refs/remotes/origin/*     regular branches from origin
│
└── refs/pull/42/head         ← tip of the PR #42 author's branch
    refs/pull/42/merge        ← preview result of main + PR #42
        ▲                       (created by GitHub; disappears if there is a conflict)
        │
        └── read-only. You cannot push here — only read.

    PR from a fork:  the branch lives in the fork,
                     but refs/pull/42/head sits in YOUR repository
                     → no need to add the fork as a remote

  green locally but red in CI?
  you are looking at /head, while CI almost always looks at /merge
🔧 Git / GitHub — що вжито в прикладі git fetch origin src:dst — refspec: забрати віддалений реф і одразу створити з нього локальну гілку; switch --detach — стати на коміт без гілки (detached HEAD, для «подивитись і піти»); main...pr-42 — три крапки, діф від merge-base, дві крапки дали б інше; --repeat-each — прогін тесту N разів, штатний спосіб ловити флейк.
🔧 Git / GitHub — what's used in this example git fetch origin src:dst — a refspec: fetch a remote ref and create a local branch from it in one go; switch --detach — stand on a commit with no branch (detached HEAD, for "look and leave"); main...pr-42 — three dots, the diff from the merge-base; two dots would give you something else; --repeat-each — run a test N times, the standard way to catch a flake.
🗣 Як читати вголос Для QA рев'ю в браузері майже нічого не дає: діф показує, що людина написала, але не показує, чи тест стабільний, скільки він іде і чи не тягне за собою сусідні спеки — це видно тільки коли запустиш локально. Найкоротший шлях — gh pr checkout і номер PR, це працює і для гілки в репозиторії, і для форку. Під капотом усе тримається на тому, що GitHub кладе в мій же репозиторій службові рефи на кожен PR: refs/pull, номер, head — це рівно те, що написав автор, і refs/pull, номер, merge — попередній результат злиття з base-гілкою. Саме тому форк не треба додавати як remote, його коміти вже в мене, і саме тому буває, що локально зелено, а CI червоний: я дивлюсь на head, а CI майже завжди ганяє merge. Забравши гілку, я запускаю тест не один раз, а разів десять поспіль з одним воркером — інакше флейк просто не покажеться. І якщо хочу щось дописати в чужий PR, пам'ятаю, що пушити можна тільки коли автор лишив увімкненим «allow edits by maintainers», а гілка, зроблена вручну з refs/pull, нікуди не трекається.
🗣 How to say it out loud For QA a browser review gives you almost nothing: the diff shows what the person wrote, but not whether the test is stable, how long it takes, or whether it drags neighbouring specs down with it — you only see that by running it locally. The shortest route is gh pr checkout and the PR number, which works both for a branch in the repo and for a fork. Underneath it works because GitHub puts read-only service refs for every PR into my own copy of the repository: refs pull, number, head is exactly what the author wrote, and refs pull, number, merge is a preview of that branch merged with the base. That's why I don't need to add the fork as a remote — its commits are already here — and it's also why you sometimes get green locally and red in CI: I'm looking at head, and CI almost always runs merge. Once I've got the branch, I run the test ten times with a single worker rather than once, because otherwise a flake simply won't show up. And if I want to add something to someone else's PR, I remember that pushing only works when the author left "allow edits by maintainers" on, and that a branch made by hand from refs pull tracks nothing.
GH4 Теги і релізи: як версіонувати тест-сьют і навіщо це взагалі?Tags and releases: how to version a test suite, and why bother at all
GitHubMiddle▶

Тег — це іменований вказівник на конкретний коміт, який, на відміну від гілки, не рухається. Гілка повзе вперед з кожним комітом; тег як прибили, так і стоїть.

A tag is a named pointer to a specific commit which, unlike a branch, doesn't move. A branch crawls forward with every commit; a tag stays nailed where you put it.

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

Two kinds of tag, and the difference isn't cosmetic:

  • Lightweight (git tag v1.4.0) — просто файл із хешем. Нема автора, дати, повідомлення. Годиться для локальної закладки «тут я був».
  • Annotated (git tag -a v1.4.0 -m "...") — повноцінний об'єкт у базі Git: автор, дата, текст, за бажання підпис. Усе, що виходить за межі твоєї машини, має бути annotated.
  • Lightweight (git tag v1.4.0) — just a file with a hash. No author, no date, no message. Fine as a local bookmark saying "I was here".
  • Annotated (git tag -a v1.4.0 -m "...") — a full object in Git's store: author, date, text, and a signature if you want one. Anything that leaves your machine should be annotated.

Реліз на GitHub — це шар поверх тега, не заміна йому. Тег живе в Git, реліз — це сторінка з нотатками й прикріпленими файлами (звіт, HTML-репорт, зібраний артефакт). Нема тега — нема релізу.

A GitHub release is a layer on top of a tag, not a replacement for it. The tag lives in Git; the release is a page with notes and attached files (a report, an HTML report, a built artifact). No tag, no release.

Головне питання: що взагалі означає «версія тест-сьюта»?

The real question: what does "a version of a test suite" even mean?

Тести самі по собі нічого не «випускають», тому семантичне версіонування в лоб сюди не лягає. Робоча схема — прив'язувати версію сьюта до релізу застосунку, який він засвідчив. Пройшла повна регресія перед релізом 3.7 — ставиш тег на той коміт сьюта, який її проганяв. Через півроку, коли треба з'ясувати, як саме тестували 3.7, ти викотиш рівно ті тести, а не сучасні.

Tests don't release anything by themselves, so semantic versioning doesn't map onto them head-on. The scheme that works is tying the suite's version to the release of the application it signed off. A full regression passed before release 3.7 — you tag the suite commit that ran it. Six months later, when somebody needs to know how 3.7 was actually tested, you check out exactly those tests rather than today's.

Другий сценарій, де це рятує — довгі релізні гілки застосунку. Продукт 3.6 ще на підтримці, а сьют уже переписаний під 4.0. Без тегів «перевірити фікс на 3.6» перетворюється на археологію.

The second scenario where this saves you is long application release branches. Product 3.6 is still supported while the suite has already been rewritten for 4.0. Without tags, "verify the fix on 3.6" turns into archaeology.

Дві пастки, на яких ловлять на співбесіді. Перша: теги не їдуть зі звичайним git push — їх треба пушити окремо. Друга: запушений тег не рухають. Технічно -f існує, але в усіх, хто вже витягнув старий тег, він так і лишиться старим, і ти отримаєш два різні «v1.4.0» у природі. Помилився — став новий тег.

Two traps interviewers catch people with. First: tags do not travel with an ordinary git push — you push them separately. Second: you don't move a tag once it has been pushed. Technically -f exists, but for everyone who already fetched the old tag it stays the old one, and now there are two different "v1.4.0" out in the wild. Got it wrong? Cut a new tag.

Теги на практиці
Tags in practice
# annotated — усе, що виходить за межі твоєї машини
git tag -a suite-3.7.0 -m "Regression suite certified against app 3.7.0"

# теги НЕ їдуть зі звичайним push — тільки явно
git push origin suite-3.7.0

# на давній коміт — теж можна, вказавши хеш
git tag -a suite-3.6.4 a1b2c3d -m "Last suite that ran against 3.6"

# що це взагалі за коміт, на якому я стою
git describe --tags
# suite-3.7.0-12-ga1b2c3d
#   ▲          ▲    ▲
#   тег        │    поточний коміт
#              12 комітів після тега

# повернутись до сьюта, яким тестували 3.6
git switch --detach suite-3.6.4

# реліз = тег + нотатки + артефакти
gh release create suite-3.7.0 \
   --title "Regression suite for 3.7.0" \
   --notes "482 tests, 0 failed, 3 skipped" \
   playwright-report.zip
# annotated — for everything that leaves your machine
git tag -a suite-3.7.0 -m "Regression suite certified against app 3.7.0"

# tags do NOT travel with a regular push — only explicitly
git push origin suite-3.7.0

# on an old commit — also possible, by passing the hash
git tag -a suite-3.6.4 a1b2c3d -m "Last suite that ran against 3.6"

# what commit am I actually standing on
git describe --tags
# suite-3.7.0-12-ga1b2c3d
#   ▲          ▲    ▲
#   tag        │    current commit
#              12 commits after the tag

# go back to the suite that was used to test 3.6
git switch --detach suite-3.6.4

# release = tag + notes + artifacts
gh release create suite-3.7.0 \
   --title "Regression suite for 3.7.0" \
   --notes "482 tests, 0 failed, 3 skipped" \
   playwright-report.zip
Один рядок у звіті CI, який вартий усієї цієї мороки
The one line in a CI report that makes all this worth the trouble
# .github/workflows/regression.yml
- name: Stamp the report with the suite version
  run: echo "suite: $(git describe --tags --always)" >> $GITHUB_STEP_SUMMARY

# у звіті:  suite: suite-3.7.0-12-ga1b2c3d
#
# через півроку питання «а якою версією тестів це ганяли?»
# перестає бути питанням

⚠ actions/checkout за замовчуванням клонує на глибину 1 —
   тегів у клоні не буде, і git describe впаде. Треба:
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
# .github/workflows/regression.yml
- name: Stamp the report with the suite version
  run: echo "suite: $(git describe --tags --always)" >> $GITHUB_STEP_SUMMARY

# in the report:  suite: suite-3.7.0-12-ga1b2c3d
#
# half a year later the question "which test version was this run with?"
# stops being a question

⚠ actions/checkout clones with depth 1 by default —
   there will be no tags in the clone, and git describe will fail. You need:
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
🔧 Git / GitHub — що вжито в прикладі git tag -a — annotated-тег як окремий об'єкт у базі Git; git describe --tags — «найближчий тег плюс скільки комітів після нього», штатний спосіб отримати версію рядком; --always — не падати, якщо тегів нема; gh release create — реліз GitHub поверх наявного тега; fetch-depth: 0 у actions/checkout — повна історія замість shallow-клону.
🔧 Git / GitHub — what's used in this example git tag -a — an annotated tag, a separate object in Git's store; git describe --tags — "the nearest tag plus how many commits since", the standard way to get a version as a string; --always — don't fail when there are no tags at all; gh release create — a GitHub release on top of an existing tag; fetch-depth: 0 in actions/checkout — full history instead of a shallow clone.
🗣 Як читати вголос Тег — це нерухомий іменований вказівник на коміт: гілка з кожним комітом повзе вперед, а тег стоїть там, де його поставили. Видів два: lightweight — просто файл із хешем, і annotated — повноцінний об'єкт з автором, датою й текстом; усе, що виходить за межі моєї машини, я роблю annotated. Реліз на GitHub — це шар поверх тега: сторінка з нотатками й прикріпленими артефактами, наприклад HTML-репортом; без тега релізу не буває. Найцікавіше питання — що означає версія тест-сьюта, адже тести самі нічого не випускають. Робоча схема — прив'язувати версію сьюта до релізу застосунку, який він засвідчив: пройшла регресія перед три-сім — ставлю тег на той коміт сьюта, і через півроку можу викотити рівно ті тести, а не сучасні. Дві типові пастки: теги не їдуть зі звичайним push, їх треба пушити окремо, і запушений тег не рухають — інакше в природі з'явиться два різні v1.4.0. А в CI варто пам'ятати, що checkout за замовчуванням робить shallow-клон без тегів, тому git describe там впаде, поки не поставиш fetch-depth нуль.
🗣 How to say it out loud A tag is an immovable named pointer to a commit: a branch crawls forward with every commit, while a tag stays where it was put. There are two kinds — lightweight, which is just a file with a hash, and annotated, which is a full object with an author, a date and a message; anything that leaves my machine I make annotated. A GitHub release is a layer on top of a tag: a page with notes and attached artifacts, an HTML report for instance, and without a tag there is no release. The interesting question is what a version of a test suite even means, since tests don't ship anything themselves. The scheme that works is tying the suite's version to the application release it signed off — the regression passed before three-seven, so I tag the suite commit that ran it, and six months later I can check out exactly those tests instead of today's. Two typical traps: tags don't travel with a normal push, you push them separately, and you don't move a pushed tag, otherwise two different v1.4.0 end up out in the wild. And in CI it's worth remembering that checkout does a shallow clone with no tags by default, so git describe will fail there until you set fetch-depth to zero.
GH5 Branch protection і required checks: які саме перевірки робити обов'язковими?Branch protection and required checks: which checks should actually be mandatory?
GitHubSenior▶

Branch protection — набір правил на гілку, які GitHub застосовує до запису: заборонити прямий push, вимагати PR, вимагати N апрувів, вимагати щоб названі перевірки були зеленими.

Branch protection is a set of rules on a branch that GitHub applies before the write: block direct pushes, require a PR, require N approvals, require named checks to be green.

Технічна частина проста, і на ній рідко валяться. Валяться на питанні «а що робити обов'язковим». Тут є дві протилежні помилки, і друга гірша.

The technical part is simple, and almost nobody fails on it. People fail on the question of what to make mandatory. There are two opposite mistakes here, and the second one is worse.

Помилка перша — не вимагати нічого. Тоді захист — це декорація: PR мержаться на червоному CI, а main живе в статусі «ну, здебільшого зелений».

Mistake one — requiring nothing. Then the protection is decoration: PRs get merged on red CI, and main lives in the state of "well, green most of the time".

Помилка друга — зробити обов'язковим повний e2e-прогін. Ось що з цього виходить насправді:

Mistake two — making the full e2e run mandatory. Here is what that actually turns into:

  • Прогін іде 40 хвилин — кожен PR стоїть 40 хвилин, і ще стільки ж після кожної правки за рев'ю.
  • Один флейкі-тест блокує всіх, хто нічого спільного з ним не має.
  • Команда вчиться тиснути «Re-run failed jobs», поки не позеленіє. Після цього перевірка не перевіряє нічого — вона просто лотерея, яку розігрують, доки не виграють.
  • З'являється людина з правом «merge without waiting», і захист остаточно стає декорацією, тільки тепер із почуттям виконаного обов'язку.
  • The run takes 40 minutes — every PR waits 40 minutes, and another 40 after every review fix.
  • One flaky test blocks everyone, including the people who have nothing to do with it.
  • The team learns to hit "Re-run failed jobs" until it goes green. After that the check checks nothing — it is a lottery you keep drawing until you win.
  • Someone shows up with the right to "merge without waiting", and the protection is decoration again, only now with a sense of duty done.

Робочий поділ — за швидкістю й детермінованістю, а не за «важливістю»:

The split that works is by speed and determinism, not by "importance":

  • Обов'язкові: lint, типи, юніти, і невеликий smoke-набір e2e — усе, що йде хвилини й падає тільки по ділу.
  • Запускаємо, показуємо, не блокуємо: повна регресія, крос-браузерна матриця, візуальні порівняння. Вони живуть на нічному розкладі й на пуші в main.
  • Тест, що флейкає, — не «майже обов'язковий». Він або лагодиться, або їде з обов'язкових сьогодні, з номером issue. Флейк в обов'язкових псує не тільки цей PR, а й довіру до всієї стіни перевірок.
  • Mandatory: lint, types, unit tests and a small e2e smoke set — everything that runs in minutes and only fails for a real reason.
  • Run it, show it, don't block on it: the full regression, the cross-browser matrix, visual comparisons. They live on a nightly schedule and on push to main.
  • A flaky test is not "almost mandatory". It either gets fixed or it leaves the required list today, with an issue number on it. A flake in the required checks ruins more than this one PR — it ruins trust in the whole wall of checks.

Пастка, на якій горять усі по одному разу: обов'язкова перевірка задається рядком з іменем job'а. Перейменував job у воркфлоу — GitHub і далі чекає на стару назву, яка вже ніколи не прийде. PR висить на «Expected — waiting for status» вічно, і ніде нема жодної помилки. Те саме буває, коли job вимкнено через if: або paths: — він не «пропущений», його просто нема. Перейменування job'а — це зміна конфігурації захисту гілки, а не косметика.

The trap everyone hits exactly once: a required check is configured by a string with the job's name. Rename the job in the workflow and GitHub keeps waiting for the old name, which is never coming. The PR hangs on "Expected — waiting for status" forever, and there is no error anywhere. The same thing happens when a job is switched off by if: or paths: — it isn't "skipped", it simply doesn't exist. Renaming a job is a change to the branch protection config, not cosmetics.

Що реально блокує мерж
What actually blocks a merge
PR #128 → main
│
├─ ✅ Require a pull request before merging      прямий push у main заборонено
│     └─ Require approvals: 1                    (2 — коли команда > 6)
│     └─ Dismiss stale approvals on new push     ← інакше апрув живе
│                                                  довше за код, який схвалили
│     └─ Require review from Code Owners         для tests/ і .github/workflows/
│
├─ ✅ Require status checks to pass
│     ├─ lint                    ~30 с   ← обов'язковий
│     ├─ typecheck               ~40 с   ← обов'язковий
│     ├─ unit                    ~2 хв   ← обов'язковий
│     ├─ e2e-smoke               ~4 хв   ← обов'язковий
│     └─ e2e-full (chromium+webkit, 480 тестів, ~40 хв)
│                                        ← НЕ обов'язковий:
│                                          іде на розкладі й на push у main
│
├─ ✅ Require conversation resolution           щоб «а що з цим?» не тонуло
├─ ✅ Require linear history                    парна річ до squash-мержу
└─ ✅ Do not allow bypassing (include admins)   інакше все вище — рекомендація
PR #128 → main
│
├─ ✅ Require a pull request before merging      direct push to main is forbidden
│     └─ Require approvals: 1                    (2 — when the team is > 6)
│     └─ Dismiss stale approvals on new push     ← otherwise an approval outlives
│                                                  the code that was approved
│     └─ Require review from Code Owners         for tests/ and .github/workflows/
│
├─ ✅ Require status checks to pass
│     ├─ lint                    ~30 s   ← required
│     ├─ typecheck               ~40 s   ← required
│     ├─ unit                    ~2 min  ← required
│     ├─ e2e-smoke               ~4 min  ← required
│     └─ e2e-full (chromium+webkit, 480 tests, ~40 min)
│                                        ← NOT required:
│                                          runs on a schedule and on push to main
│
├─ ✅ Require conversation resolution           so "what about this?" does not get lost
├─ ✅ Require linear history                    goes hand in hand with squash merge
└─ ✅ Do not allow bypassing (include admins)   otherwise everything above is a suggestion
Мовчазна поломка: перейменований job
The silent breakage: a renamed job
# було — і саме "unit" записано в обов'язкові перевірки
jobs:
  unit:
    runs-on: ubuntu-latest

# стало — «трошки причесали назви»
jobs:
  unit-tests:          ← перейменували job
    runs-on: ubuntu-latest

# що бачить розробник у PR:
#
#   ⏳ Expected — Waiting for status to be reported
#
# CI зелений. Job відпрацював. Мерж заблоковано назавжди,
# бо GitHub чекає на перевірку з назвою "unit", а її більше не існує.
# Жодної помилки ніде не буде.

# перевірити, що саме зараз вимагається:
gh api repos/:owner/:repo/branches/main/protection/required_status_checks
# before — and exactly "unit" is listed in the required checks
jobs:
  unit:
    runs-on: ubuntu-latest

# after — "we just tidied up the names a bit"
jobs:
  unit-tests:          ← the job was renamed
    runs-on: ubuntu-latest

# what the developer sees in the PR:
#
#   ⏳ Expected — Waiting for status to be reported
#
# CI is green. The job finished. The merge is blocked forever,
# because GitHub is waiting for a check named "unit", and it no longer exists.
# There will be no error anywhere.

# check what exactly is required right now:
gh api repos/:owner/:repo/branches/main/protection/required_status_checks
🔧 Git / GitHub — що вжито в прикладі Branch protection rules / rulesets — правила на гілку в налаштуваннях репозиторію; required status checks зіставляються за назвою job'а, а не за файлом воркфлоу; CODEOWNERS — файл, що прив'язує шляхи до обов'язкових рев'юерів; gh api — прямий доступ до REST API GitHub із консолі, коли треба побачити конфіг, а не клікати по вкладках.
🔧 Git / GitHub — what's used in this example Branch protection rules / rulesets — per-branch rules in the repository settings; required status checks are matched by the job's name, not by the workflow file; CODEOWNERS — a file that ties paths to mandatory reviewers; gh api — direct access to GitHub's REST API from the console, for when you need to see the config rather than click through tabs.
🗣 Як читати вголос Branch protection — це правила, які GitHub застосовує до запису в гілку: заборонити прямий push, вимагати PR, вимагати апрув, вимагати зелені перевірки. Технічна частина проста, цікаве починається на питанні, що саме робити обов'язковим. Не вимагати нічого — означає перетворити захист на декорацію, але зробити обов'язковим повний e2e-прогін ще гірше: кожен PR стоїть сорок хвилин, один флейкі-тест блокує людей, які до нього не дотичні, і команда швидко вчиться тиснути re-run, поки не позеленіє — після цього перевірка вже нічого не перевіряє. Я ділю не за важливістю, а за швидкістю й детермінованістю: обов'язкові — лінт, типи, юніти й невеликий smoke на кілька хвилин, а повна регресія й крос-браузерна матриця запускаються, показуються, але не блокують, і живуть на нічному розкладі та на пуші в main. Тест, який флейкає, я не тримаю в обов'язкових ні дня: він або лагодиться, або виїжджає звідти з номером issue. І окремо назву пастку, на якій горять усі: обов'язкова перевірка задається рядком з іменем job'а, тому варто перейменувати job — і PR назавжди зависає на «waiting for status», без жодної помилки, бо GitHub чекає на назву, якої більше не існує.
🗣 How to say it out loud Branch protection is the set of rules GitHub applies before anything is written to a branch: block direct pushes, require a PR, require an approval, require green checks. The technical part is simple; the interesting part is deciding what to actually make mandatory. Requiring nothing turns the protection into decoration, but requiring the full e2e run is worse: every PR sits there for forty minutes, one flaky test blocks people who never touched it, and the team quickly learns to hit re-run until it goes green — at which point the check isn't checking anything any more. So I split by speed and determinism rather than by importance: lint, types, units and a small smoke suite that finishes in a couple of minutes are mandatory, while the full regression and the cross-browser matrix run, get reported, and don't block — they live on a nightly schedule and on push to main. A test that flakes doesn't stay in the required list for a single day: it either gets fixed or it moves out with an issue number. And I'd call out the trap everyone hits: the required check is a string with the job's name, so rename the job and the PR hangs on "waiting for status" forever, with no error at all, because GitHub is waiting for a name that no longer exists.
GH6 Merge commit, Squash чи Rebase — яку кнопку тиснути в тест-репозиторії?Merge commit, squash or rebase — which button do you press in a test repo?
GitHubSenior▶

У кнопки Merge на GitHub три режими, і вони дають три різні історії. Питання не «який правильний», а «яку операцію ти реально виконуєш над main найчастіше».

GitHub's Merge button has three modes, and they give you three different histories. The question isn't which one is correct, it's which operation on main you actually perform most often.

Create a merge commit — усі коміти гілки лягають у main як були, плюс merge-коміт з двома батьками.

Create a merge commit — every commit on the branch lands in main as it was, plus a merge commit with two parents.

Історія чесна: видно, що п'ять комітів приїхали разом і звідки. Ціна — main набитий проміжними «wip», «fix typo», «ще раз», і по такій історії боляче ходити.

The history is honest: you can see that five commits arrived together and where they came from. The price is a main stuffed with intermediate "wip", "fix typo", "one more time" — and walking through a history like that hurts.

Squash and merge — усі коміти гілки склеюються в один новий коміт на main.

Squash and merge — all the branch's commits are glued into one new commit on main.

main стає рівним списком «один PR — один коміт». Ціна — внутрішня історія гілки зникає, а сама гілка лишається «не змерженою» з погляду Git: її коміти в main не потрапили, туди потрапила їхня сума. Тому git branch --merged її не покаже, а спроба змержити ту саму гілку вдруге принесе все наново.

main becomes a flat list of "one PR, one commit". The price is that the branch's internal history disappears, and the branch itself stays "unmerged" as far as Git is concerned: its commits never landed in main, their sum did. So git branch --merged won't list it, and merging the same branch a second time brings everything in again.

Rebase and merge — коміти по одному перекладаються на вершину main без merge-коміта.

Rebase and merge — the commits are replayed onto the tip of main one by one, with no merge commit.

Історія лінійна й повна. Ціна — коміти переписуються, у них нові хеші; локальна гілка автора після цього розходиться з тим, що в main, а підписи, якщо вони налаштовані, злітають.

The history is linear and complete. The price is that the commits are rewritten and get new hashes; the author's local branch then diverges from what is in main, and signatures, if you have them, fall off.

А тепер критерій, а не смак. Запитай себе: яку операцію над історією main ти виконуєш найчастіше?

Now a criterion instead of taste. Ask yourself: which operation on main's history do you perform most often?

У тест-репозиторії відповідь майже завжди одна — git bisect, коли треба знайти коміт, після якого тест почав падати. Bisect половинним пошуком ходить по історії й на кожному кроці питає «тут добре чи погано». Він працює рівно доти, доки кожен коміт у main можна зібрати й прогнати. Проміжні «wip», які не збираються, дають git bisect skip, і пошук розмивається.

In a test repo the answer is almost always the same — git bisect, when you need to find the commit after which a test started failing. Bisect walks the history by binary search and asks "good or bad here?" at every step. It works exactly as long as every commit in main can be built and run. Intermediate "wip" commits that don't build turn into git bisect skip, and the search goes blurry.

Squash дає історію, де кожен коміт — це цілий, зелений, відрев'ювений PR. Це найкраща історія для bisect, яку взагалі можна мати. Тому: squash за замовчуванням, а зайві режими просто вимкнути в налаштуваннях репозиторію — інакше вибір щоразу залежить від того, хто мержить.

Squash gives you a history where every commit is a whole, green, reviewed PR. That is the best history for bisect you can possibly have. So: squash by default, and simply switch the other modes off in the repository settings — otherwise the choice depends on whoever happens to be merging that day.

Коли squash — неправильно. Довгі релізні гілки: коли release/3.6 вливається назад у main, squash перетворює двадцять окремих фіксів на один коміт, і після цього git cherry вже не бачить, що саме туди їхало, а наступний мерж принесе конфлікти на змінах, які насправді вже на місці. Для гілки-в-гілку роби звичайний merge-коміт. Squash — для PR від фіча-гілки в main, і тільки.

When squash is the wrong call. Long-lived release branches: when release/3.6 flows back into main, squash turns twenty separate fixes into one commit, and after that git cherry can no longer see what actually went in, while the next merge brings conflicts on changes that are in fact already there. For branch-into-branch use an ordinary merge commit. Squash is for a PR from a feature branch into main, and nothing else.

Три кнопки — три історії
Three buttons — three histories
гілка:  main …── C          PR: 5 комітів (з них два — «wip» і «fix typo»)
                  \
                   f1─f2─f3─f4─f5


① Create a merge commit
   main …── C ──────────────────── M          M = merge-коміт, 2 батьки
              \                   /
               f1─f2─f3─f4─f5────┘
   історія чесна · main захаращений · bisect бродить по f2, який не збирається


② Squash and merge
   main …── C ── S                             S = один новий коміт
   гілка          f1…f5  (лишились осторонь, у main їх нема)
   main чистий · «один PR — один коміт» · внутрішня історія втрачена
   для git bisect — найкраще, бо кожен коміт у main зелений і цілий


③ Rebase and merge
   main …── C ── f1'─f2'─f3'─f4'─f5'           штрих = НОВІ хеші
   лінійно й повно · f1..f5 переписані · локальна гілка автора розійшлась
branch: main …── C          PR: 5 commits (two of them are "wip" and "fix typo")
                  \
                   f1─f2─f3─f4─f5


① Create a merge commit
   main …── C ──────────────────── M          M = merge commit, 2 parents
              \                   /
               f1─f2─f3─f4─f5────┘
   honest history · cluttered main · bisect wanders into f2, which does not build


② Squash and merge
   main …── C ── S                             S = one new commit
   branch         f1…f5  (left aside, not in main)
   clean main · "one PR — one commit" · internal history is lost
   best for git bisect, because every commit in main is green and complete


③ Rebase and merge
   main …── C ── f1'─f2'─f3'─f4'─f5'           prime = NEW hashes
   linear and complete · f1..f5 rewritten · the author's local branch has diverged
Заради чого обрано squash
What squash was chosen for
# тест почав падати десь за останні два тижні. Де саме?
git bisect start
git bisect bad  HEAD
git bisect good suite-3.7.0

# half-автоматично: bisect сам прогонить тест на кожному кроці
git bisect run npx playwright test tests/checkout/guest.spec.ts

# … 9 кроків замість 500 комітів …
# a1b2c3d is the first bad commit
#   test(checkout): switch guest flow to the new fixture

git bisect reset

⚠ якщо в main лежать проміжні «wip», які не збираються, кожен такий
   коміт — це bisect skip, і замість точного коміта отримаєш діапазон.
   Squash-історія цієї проблеми не має за побудовою.
# the test started failing somewhere in the last two weeks. Where exactly?
git bisect start
git bisect bad  HEAD
git bisect good suite-3.7.0

# semi-automatic: bisect runs the test itself at every step
git bisect run npx playwright test tests/checkout/guest.spec.ts

# … 9 steps instead of 500 commits …
# a1b2c3d is the first bad commit
#   test(checkout): switch guest flow to the new fixture

git bisect reset

⚠ if main contains intermediate "wip" commits that do not build, every such
   commit is a bisect skip, and instead of the exact commit you get a range.
   Squash history does not have this problem by construction.
🔧 Git / GitHub — що вжито в прикладі Три режими кнопки Merge вмикаються й вимикаються в Settings репозиторію — не залишай усі три; git bisect run <команда> — автоматичний половинний пошук, «погано» визначається ненульовим кодом виходу; git branch --merged після squash не покаже гілку, бо її коміти в main так і не потрапили; «Require linear history» у branch protection — парна налаштування до squash або rebase.
🔧 Git / GitHub — what's used in this example The three Merge-button modes are turned on and off in the repository Settings — don't leave all three enabled; git bisect run <command> — automatic binary search where "bad" means a non-zero exit code; git branch --merged won't show a squashed branch, because its commits never reached main; "Require linear history" in branch protection — the setting that pairs with squash or rebase.
🗣 Як читати вголос Кнопка Merge на GitHub має три режими, і вони дають три різні історії. Merge-коміт зберігає всі коміти гілки й додає коміт злиття — історія чесна, але main захаращений усіма «wip» і «fix typo». Squash склеює гілку в один коміт: main стає рівним списком «один PR — один коміт», але внутрішня історія зникає, і з погляду Git гілка лишається незмерженою, бо в main потрапили не її коміти, а їхня сума. Rebase перекладає коміти на вершину main без коміта злиття — лінійно й повно, але з новими хешами, тому локальна гілка автора після цього розходиться. Вибирати я пропоную не за смаком, а за питанням, яку операцію над історією main виконую найчастіше — і в тест-репозиторії це майже завжди git bisect, пошук коміта, після якого тест почав падати. Bisect працює рівно доти, доки кожен коміт можна зібрати й прогнати, а squash дає історію, де кожен коміт — це цілий зелений відрев'ювений PR, тобто ідеальний матеріал для bisect. Тому squash за замовчуванням, а решту режимів просто вимикаю в налаштуваннях. Виняток — довгі релізні гілки: коли вливаю release назад у main, беру звичайний merge-коміт, бо squash там ховає, що саме туди їхало.
🗣 How to say it out loud GitHub's Merge button has three modes and they give you three different histories. A merge commit keeps every commit from the branch and adds a commit of its own — honest history, but main is buried in "wip" and "fix typo". Squash glues the branch into a single commit: main becomes a flat list of one PR per commit, but the internal history is gone, and as far as Git is concerned the branch is still unmerged, because what landed in main was the sum of its commits rather than the commits themselves. Rebase replays them onto the tip of main with no merge commit — linear and complete, but with new hashes, so the author's local branch diverges afterwards. I'd rather pick by a criterion than by taste, and the criterion is which operation on main's history I perform most often — in a test repo that's almost always git bisect, hunting the commit after which a test started failing. Bisect works exactly as long as every commit can be built and run, and squash gives me a history where each commit is a whole green reviewed PR, which is the perfect material for it. So squash by default, and I turn the other modes off in the settings entirely. The exception is long-lived release branches: merging a release back into main I take a real merge commit, because squash there hides what actually went in.
GH7 Cherry-pick: коли це правильна відповідь, а коли — симптом поганого процесу?Cherry-pick: when is it the right answer, and when is it a symptom of a bad process?
GitHubSenior▶

git cherry-pick <sha> бере діф одного коміта й накладає його на те, де ти зараз стоїш. Результат — новий коміт із новим хешем і тією ж зміною. Це не «перенести коміт», це «застосувати ту саму латку в іншому місці».

git cherry-pick <sha> takes one commit's diff and applies it where you are standing right now. The result is a new commit, with a new hash and the same change. It isn't "move a commit", it's "apply the same patch somewhere else".

Коли це саме те, що треба:

When it is exactly the right tool:

  • Бекпорт хотфіксу. Фікс уже в main, його треба в release/3.6, яка на підтримці. Мержити main у релізну гілку не можна — приїде сорок сторонніх комітів. Забираєш один.
  • Урятувати роботу не з тієї гілки. Наробив комітів у main замість фіча-гілки — переносиш їх туди, куди треба.
  • Витягти шматок із застряглого PR. Великий PR застряг у рев'ю, а одне виправлення в ньому потрібне всім уже зараз.
  • Backporting a hotfix. The fix is already in main and it's needed in release/3.6, which is still supported. You can't merge main into the release branch — forty unrelated commits would come with it. So you take one.
  • Rescuing work from the wrong branch. You made commits on main instead of a feature branch — you move them where they belong.
  • Pulling one piece out of a stuck PR. A big PR is stuck in review, and one fix inside it is needed by everyone right now.

Коли cherry-pick — це червоний прапорець:

When cherry-pick is a red flag:

Якщо ним регулярно тягають зміни між двома довгоживучими гілками замість того, щоб мержити. Тоді ті самі зміни існують у двох екземплярах із різними хешами; Git не знає, що це одне й те саме, і при наступному справжньому мержі покаже конфлікти на коді, який насправді вже на місці. Одноразовий бекпорт — інструмент. Cherry-pick як спосіб життя — це діагноз гілковій моделі.

When it is used routinely to drag changes between two long-lived branches instead of merging them. Then the same change exists in two copies with different hashes; Git has no idea they are the same thing, and the next real merge will show conflicts on code that is in fact already in place. A one-off backport is a tool. Cherry-pick as a way of life is a diagnosis for your branching model.

І головне для QA — те, про що майже не думають.

And the part that matters most for QA — the part almost nobody thinks about.

Чистий cherry-pick не означає правильний cherry-pick. Git накладає латку на інший код. Якщо в main page object уже переписали, а в release/3.6 він старий — латка може лягти без жодного конфлікту, зібратись, запуститись і при цьому перевіряти вже не те. Конфлікти ловлять текстові розбіжності, а не смислові. Після кожного бекпорту тести проганяються на цільовій гілці, і це не формальність.

A clean cherry-pick doesn't mean a correct cherry-pick. Git applies the patch to different code. If the page object has already been rewritten in main while release/3.6 still has the old one, the patch can land with no conflict at all, compile, run — and check something else entirely. Conflicts catch textual divergence, not semantic divergence. After every backport you run the tests on the target branch, and that isn't a formality.

Завжди -x для бекпортів. Прапорець дописує в повідомлення рядок «(cherry picked from commit …)». Без нього через три місяці неможливо відповісти на питання «а цей фікс у релізній гілці вже є?» — хеші ж різні. З ним відповідь є в самому коміті, і git log --grep її знаходить.

Always use -x for backports. The flag appends a "(cherry picked from commit …)" line to the message. Without it, three months later there is no way to answer "is this fix already in the release branch?" — the hashes are different, after all. With it the answer lives in the commit itself, and git log --grep finds it.

Бекпорт хотфіксу в релізну гілку
Backporting a hotfix into a release branch
# фікс уже в main; треба його ж у release/3.6, яка на підтримці
git log --oneline main -5
# a1b2c3d fix(auth): reject expired refresh token   ← оцей

git switch release/3.6
git cherry-pick -x a1b2c3d
#          ▲
#          └── допише «(cherry picked from commit a1b2c3d)»
#              — єдиний спосіб потім дізнатись, звідки це приїхало

# діапазон комітів (перша крапка не включно):
git cherry-pick -x a1b2c3d..e4f5g6h

# якщо конфлікт — правиш, і НЕ робиш звичайний commit:
git add .
git cherry-pick --continue        # або --abort, щоб відкотити все

# ⚠ обов'язковий крок, який пропускають:
npx playwright test tests/auth   # чисто накладене ≠ правильно накладене
# the fix is already in main; we need it in release/3.6 too, which is under maintenance
git log --oneline main -5
# a1b2c3d fix(auth): reject expired refresh token   ← this one

git switch release/3.6
git cherry-pick -x a1b2c3d
#          ▲
#          └── appends "(cherry picked from commit a1b2c3d)"
#              — the only way to find out later where this came from

# a range of commits (the first one is exclusive):
git cherry-pick -x a1b2c3d..e4f5g6h

# on a conflict — fix it, and do NOT make a regular commit:
git add .
git cherry-pick --continue        # or --abort to roll everything back

# ⚠ the mandatory step people skip:
npx playwright test tests/auth   # applied cleanly ≠ applied correctly
Чому «без конфліктів» нічого не гарантує
Why "no conflicts" guarantees nothing
main                      release/3.6
────────────              ────────────
LoginPage переписали      LoginPage старий
на getByRole              на CSS-селекторах

        коміт a1b2c3d: «чекати на /refresh перед асертом»
        діф чіпає рядок з await, а не з локатором
                    │
                    ▼
        cherry-pick лягає ЧИСТО. Конфліктів нема.
        Код збирається. Тест зелений.
        І перевіряє не те, бо селектор поруч — з іншої епохи.

  Git зіставляє текст. Сенсу він не бачить.
  Тому після бекпорту тести на цільовій гілці — не формальність.


  «а цей фікс уже в 3.6?» — хеші різні, очима не знайдеш:

  git cherry -v main release/3.6      + = ще НЕ в release/3.6
                                      - = вже там (є еквівалент)
  git log --grep='cherry picked from commit a1b2c3d' release/3.6
main                      release/3.6
────────────              ────────────
LoginPage rewritten        LoginPage is old
with getByRole             with CSS selectors

        commit a1b2c3d: "wait for /refresh before the assert"
        the diff touches the await line, not the locator line
                    │
                    ▼
        cherry-pick applies CLEANLY. No conflicts.
        The code builds. The test is green.
        And it checks the wrong thing, because the selector next to it is from another era.

  Git matches text. It does not see meaning.
  That is why running tests on the target branch after a backport is not a formality.


  "is this fix already in 3.6?" — the hashes differ, you will not spot it by eye:

  git cherry -v main release/3.6      + = NOT in release/3.6 yet
                                      - = already there (an equivalent exists)
  git log --grep='cherry picked from commit a1b2c3d' release/3.6
🔧 Git / GitHub — що вжито в прикладі cherry-pick -x — записує вихідний хеш у повідомлення (роби це для всіх бекпортів); A..B — діапазон комітів, ліва межа не включається; --continue / --abort — вихід із конфлікту, звичайний git commit посеред cherry-pick ламає стан; git cherry -v — які коміти однієї гілки ще не мають еквівалента в іншій, порівнюючи діфи, а не хеші.
🔧 Git / GitHub — what's used in this example cherry-pick -x — writes the source hash into the message (do this for every backport); A..B — a commit range whose left boundary is excluded; --continue / --abort — the way out of a conflict, since a plain git commit in the middle of a cherry-pick breaks the state; git cherry -v — which commits on one branch have no equivalent on the other, compared by diff rather than by hash.
🗣 Як читати вголос Cherry-pick бере діф одного коміта й накладає його там, де я зараз стою, створюючи новий коміт з новим хешем — це не перенесення коміта, а застосування тієї самої латки в іншому місці. Правильні сценарії досить вузькі: бекпорт хотфіксу в релізну гілку, яка на підтримці, коли мерж усього main притягнув би сорок сторонніх комітів; порятунок комітів, зроблених не в тій гілці; або витягування одного потрібного виправлення із застряглого в рев'ю великого PR. А ось якщо cherry-pick стає регулярним способом тягати зміни між двома довгоживучими гілками замість мержу — це вже діагноз гілковій моделі: та сама зміна існує у двох екземплярах з різними хешами, Git не знає, що це одне й те саме, і при наступному справжньому мержі покаже конфлікти на коді, який уже на місці. Для QA найважливіше інше: чистий cherry-pick не означає правильний. Git накладає латку на інший код і зіставляє текст, а не сенс — якщо на цільовій гілці page object ще старий, латка може лягти без конфліктів, зібратись і перевіряти вже не те. Тому після кожного бекпорту я ганяю тести на цільовій гілці, і завжди роблю cherry-pick з прапорцем -x, щоб через три місяці можна було відповісти, звідки цей коміт приїхав.
🗣 How to say it out loud Cherry-pick takes one commit's diff and applies it wherever I'm standing, creating a new commit with a new hash — so it isn't moving a commit, it's applying the same patch somewhere else. The legitimate cases are fairly narrow: backporting a hotfix into a release branch that's still supported, where merging all of main would drag in forty unrelated commits; rescuing commits I made on the wrong branch; or lifting one needed fix out of a big PR that's stuck in review. Where it turns into a red flag is when cherry-pick becomes the routine way of dragging changes between two long-lived branches instead of merging — that's a diagnosis for the branching model, because the same change now exists twice with different hashes, Git doesn't know they're the same thing, and the next real merge throws conflicts on code that's already there. For QA the important part is something else: a clean cherry-pick doesn't mean a correct one. Git applies the patch to different code and it matches text, not meaning — if the page object on the target branch is still the old one, the patch can land with no conflict, compile, and quietly check the wrong thing. So after every backport I run the tests on the target branch, and I always cherry-pick with -x, so that three months later you can still tell where the commit came from.
GH8 Два PR зелені, а після мержу main червоний. Як таке буває і що з цим робити?Two PRs are green, and main is red after the merge. How does that happen and what do you do about it?
GitHubSenior▶

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

This is called a semantic conflict, and this question almost always separates the people who set CI up from the people who only used it.

Корінь у тому, що CI перевіряє не те, що ти думаєш. Перевірки в PR ганяються на злитті твоєї гілки з тим main, який був у момент запуску. Не з тим, у який твоя гілка потрапить. Поки твій PR стояв на рев'ю, у main уже приїхало щось інше — і його ніхто не перевіряв разом із твоїм.

The root of it is that CI doesn't check what you think it checks. A PR's checks run on the merge of your branch with the main that existed when the run started. Not the main your branch will actually land in. While your PR sat in review something else already arrived in main — and nobody ever tested it together with yours.

Класичний приклад саме з тест-репозиторію:

The classic example, straight out of a test repo:

  • PR A перейменовує метод LoginPage.login() на signIn() і чинить усі наявні виклики. Зелений.
  • PR B додає новий тест, який кличе login() — бо на момент відгалуження метод так і звався. Теж зелений.
  • Обидва мержаться. У main тест кличе метод, якого вже нема. Червоно.
  • PR A renames LoginPage.login() to signIn() and fixes every existing call. Green.
  • PR B adds a new test that calls login() — because that was still the name when the branch was cut. Also green.
  • Both get merged. In main, the test calls a method that no longer exists. Red.

Текстового конфлікту тут нема взагалі — вони чіпали різні файли. Git зробив усе бездоганно. Зламався сенс, а не текст, і жоден інструмент злиття цього не ловить.

There is no textual conflict here at all — they touched different files. Git did its job flawlessly. What broke was the meaning, not the text, and no merge tool catches that.

Варіанти лікування — від дешевого до дорогого:

The cures, from cheap to expensive:

  • Короткі PR. Найдешевше і найдієвіше. Вікно для семантичного конфлікту — це час життя гілки. PR, що живе три дні, збирає їх сам.
  • «Require branches to be up to date before merging». GitHub не дасть змержити, поки гілка не оновлена до вершини main, і після оновлення CI перезапуститься. Дірку закриває надійно, але серіалізує чергу: кожен мерж робить усі інші PR застарілими. Утрьох — нормально, вдвадцятьох — параліч.
  • Merge queue. GitHub сам збирає спекулятивну гілку main + A + B, ганяє на ній перевірки й мержить тільки те, що позеленіло, викидаючи винуватця з черги. Це та сама перевірка «разом», але без ручного оновлення двадцяти гілок. Правильна відповідь для великої команди.
  • Прогін по main після мержу. Не запобігає, але дає чесну відповідь на питання «а main зараз зелений?» — і саме на нього треба дивитись, а не на галочку в останньому PR.
  • Short PRs. The cheapest and the most effective. The window for a semantic conflict is exactly the lifetime of the branch. A PR that lives three days collects them by itself.
  • "Require branches to be up to date before merging". GitHub won't let you merge until the branch is updated to the tip of main, and CI re-runs after the update. It closes the hole properly, but it serialises the queue: every merge makes every other PR stale. With three people that's fine; with twenty it's gridlock.
  • Merge queue. GitHub builds a speculative main + A + B branch itself, runs the checks on it and merges only what came out green, kicking the culprit out of the queue. It's the same "test them together" idea without updating twenty branches by hand. The right answer for a big team.
  • A run on main after every merge. It prevents nothing, but it gives an honest answer to "is main green right now?" — and that is what you should be looking at, not the tick on the last PR.

Що варто сказати вголос під кінець: «зелений PR» і «зелений main» — це два різні твердження, і перше не тягне за собою друге. Тому у здорових командах статус релізу читають із прогону по main, а не зі значка в PR. І тому питання «а що у нас гарантує, що main зелений?» — цілком доречне зустрічне питання на співбесіді.

What is worth saying out loud at the end: "green PR" and "green main" are two different statements, and the first does not imply the second. That is why healthy teams read release status from the run on main rather than from a badge in a PR. And it is why "what is it that guarantees main is green for us?" is a perfectly fair question to ask back in an interview.

Як це виглядає в часі
How it looks on a timeline
час ──────────────────────────────────────────────────────────►

main   C0                                    C0+A+B  ✗ ЧЕРВОНО
        │                                      ▲
        ├── PR A  rename login() → signIn()     │
        │   CI на C0+A ....................✓ ───┤
        │                                       │
        └── PR B  новий тест кличе login()      │
            CI на C0+B ....................✓ ───┘

   Обидва зелені. Жодного текстового конфлікту — різні файли.
   Ніхто ніколи не перевіряв C0+A+B — а це те, що поїхало в main.


РІШЕННЯ ①  require branches to be up to date
   PR B змушений підтягнути main з A → CI перезапускається → ✗ ловимо
   ціна: кожен мерж робить усі інші PR застарілими (черга серіалізується)

РІШЕННЯ ②  merge queue
   GitHub сам будує main+A+B, ганяє CI на ній,
   мержить тільки зелене, винуватця викидає з черги
   ціна: складніше налаштувати · окупається від ~10 активних PR

РІШЕННЯ ③  короткі PR
   вікно для семантичного конфлікту = час життя гілки
   найдешевше з усього, що тут перелічено
time ─────────────────────────────────────────────────────────►

main   C0                                    C0+A+B  ✗ RED
        │                                      ▲
        ├── PR A  rename login() → signIn()     │
        │   CI on C0+A ....................✓ ───┤
        │                                       │
        └── PR B  new test calls login()        │
            CI on C0+B ....................✓ ───┘

   Both are green. No textual conflict — different files.
   Nobody ever tested C0+A+B — and that is what went into main.


FIX ①  require branches to be up to date
   PR B has to pull in main with A → CI reruns → ✗ caught
   cost: every merge makes all other PRs outdated (the queue gets serialized)

FIX ②  merge queue
   GitHub builds main+A+B itself, runs CI on it,
   merges only what is green, kicks the culprit out of the queue
   cost: harder to set up · pays off from ~10 active PRs

FIX ③  short PRs
   window for a semantic conflict = lifetime of the branch
   the cheapest of everything listed here
Мінімум, який має бути в будь-якому репозиторії
The minimum any repository should have
# .github/workflows/main-health.yml
# перевірки в PR кажуть «моя гілка ок».
# ЦЕ каже «main ок» — і це різні твердження.
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'      # і вночі — по повній регресії

# локально перевірити себе перед мержем — те саме, що зробить
# «update branch», тільки без п'яти хвилин очікування в UI:
git fetch origin
git switch my-branch
git rebase origin/main
npx playwright test tests/smoke

# або кнопкою/командою — GitHub підтягне main у гілку сам:
gh pr update-branch 128
# .github/workflows/main-health.yml
# the checks in a PR say "my branch is ok".
# THIS says "main is ok" — and those are different statements.
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 2 * * *'      # and at night — the full regression

# check yourself locally before merging — the same thing
# "update branch" does, just without five minutes of waiting in the UI:
git fetch origin
git switch my-branch
git rebase origin/main
npx playwright test tests/smoke

# or via button/command — GitHub pulls main into the branch itself:
gh pr update-branch 128
🔧 Git / GitHub — що вжито в прикладі «Require branches to be up to date before merging» — окрема галочка всередині required status checks, не те саме, що самі перевірки; merge queue — вбудована черга GitHub зі спекулятивною збіркою; on: push: branches: [main] — воркфлоу здоров'я гілки, окремо від pull_request; gh pr update-branch — оновити гілку PR до вершини base без ручного rebase.
🔧 Git / GitHub — what's used in this example "Require branches to be up to date before merging" — a separate checkbox inside required status checks, not the same thing as the checks themselves; merge queue — GitHub's built-in queue with speculative builds; on: push: branches: [main] — a branch-health workflow, separate from pull_request; gh pr update-branch — update a PR branch to the tip of its base without rebasing by hand.
🗣 Як читати вголос Це семантичний конфлікт, і причина в тому, що CI перевіряє не те, що здається: перевірки в PR ганяються на злитті моєї гілки з тим main, який був у момент запуску, а не з тим, у який гілка врешті потрапить. Класичний приклад із тест-репозиторію: один PR перейменовує метод page object'а й чинить усі наявні виклики, другий додає новий тест, який кличе стару назву, бо на момент відгалуження вона ще існувала. Обидва зелені, текстового конфлікту нема взагалі — файли різні, Git відпрацював бездоганно, зламався сенс, а не текст. Найдешевше лікування — короткі PR, бо вікно для такого конфлікту дорівнює часу життя гілки. Далі йде галочка «require branches to be up to date», яка змушує оновити гілку й перезапустити CI перед мержем — дірку закриває, але серіалізує чергу, бо кожен мерж робить усі інші PR застарілими. Для великої команди правильна відповідь — merge queue: GitHub сам збирає спекулятивну гілку з кількох PR, ганяє на ній перевірки й мержить тільки те, що позеленіло. І в будь-якому разі має бути окремий прогін по main після мержу, бо «зелений PR» і «зелений main» — два різні твердження, і статус релізу я читаю саме з другого.
🗣 How to say it out loud That's a semantic conflict, and the reason is that CI doesn't check what it looks like it checks: a PR's checks run on your branch merged with the main that existed when the run started, not the main your branch will eventually land in. The classic test-repo example is one PR renaming a page object method and fixing all the existing calls, while another PR adds a new test that calls the old name, because that name still existed when the branch was cut. Both go green, there's no textual conflict at all — different files, Git did everything right, what broke was the meaning rather than the text. The cheapest cure is short PRs, because the window for this is exactly how long the branch lives. After that comes "require branches to be up to date", which forces an update and a CI re-run before the merge — it closes the hole, but it serialises the queue, since every merge makes every other PR stale. For a big team the right answer is a merge queue: GitHub builds a speculative branch from several PRs, runs the checks on it and merges only what came out green. And either way there should be a separate run on main after the merge, because "green PR" and "green main" are two different statements, and I read the release status from the second one.
▶ 4 8
CI-1 З чого складається workflow GitHub Actions і що з цього реально виконується?What is a GitHub Actions workflow made of, and which parts actually execute?
CI для QACI for QA Junior ▶

Workflow — це YAML-файл у теці .github/workflows/. GitHub забирає звідти всі файли з розширенням .yml або .yaml, тому ім'я файлу ні на що не впливає — e2e.yml і tests.yml для GitHub однаково валідні. Немає жодного реєстру, який треба оновити: поклала файл у гілку — з наступного тригера він живий.

A workflow is a YAML file in the .github/workflows/ folder. GitHub picks up every file there with a .yml or .yaml extension, so the file name means nothing — e2e.yml and tests.yml are equally valid to GitHub. There is no registry to update: drop the file into a branch and it is live from the next trigger on.

Усередині рівно чотири рівні, і плутати їх — найчастіша помилка на старті:

Inside there are exactly four levels, and mixing them up is the most common beginner's mistake:

  • workflow — весь файл. У нього є on: (коли запускатись) і список jobs.
  • job — окрема віртуальна машина. Не процес, не контейнер усередині спільної машини — саме окрема чиста машина, яку піднімають під цей job і знищують після нього.
  • step — одна дія всередині job. Кроки одного job ділять диск і робочу теку, бо це буквально одна машина.
  • run або uses — що саме крок робить. run — це shell-команда. uses — чужий готовий крок з маркетплейсу.
  • workflow — the whole file. It has an on: block (when to run) and a list of jobs.
  • job — a separate virtual machine. Not a process, not a container inside a shared machine — an actual clean machine, brought up for this job and destroyed after it.
  • step — one action inside a job. Steps in the same job share the disk and the working directory, because they are literally on one machine.
  • run or uses — what the step actually does. run is a shell command. uses is somebody else's ready-made step from the marketplace.
workflow  (e2e.yml)
   │  on: pull_request
   │
   ├── job "lint"   ─── своя чиста машина ─┐
   │      step: checkout                   │  ці два job ідуть
   │      step: run npm run lint           │  ОДНОЧАСНО, вони
   │                                       │  не бачать один
   └── job "e2e"    ─── своя чиста машина ─┘  одного взагалі
          step: checkout          ← без цього тека порожня
          step: setup-node
          step: run npx playwright test
workflow  (e2e.yml)
   │  on: pull_request
   │
   ├── job "lint"   ─── own clean machine ─┐
   │      step: checkout                   │  these two jobs run
   │      step: run npm run lint           │  AT THE SAME TIME, they
   │                                       │  do not see each other
   └── job "e2e"    ─── own clean machine ─┘  at all
          step: checkout          ← without it the folder is empty
          step: setup-node
          step: run npx playwright test

Jobs за замовчуванням паралельні. Це вмикається саме тим, що вони на різних машинах — і тому job B не побачить файлів, які створив job A. Якщо потрібна послідовність, її треба попросити явно через needs:, а якщо потрібно передати файли — тільки через артефакти, бо спільного диска немає.

Jobs run in parallel by default. That falls straight out of them being on different machines — which is also why job B will not see the files job A created. If you need them in order, you ask for it explicitly with needs:, and if you need to pass files across, artifacts are the only way, because there is no shared disk.

Чому перший крок майже завжди actions/checkout. Раннер стартує з порожньою робочою текою: там немає ані твого коду, ані git-історії. GitHub не кладе репозиторій на машину сам. checkout — це і є той крок, який робить git clone потрібної гілки.

Why the first step is almost always actions/checkout. The runner starts with an empty working directory: no code, no git history. GitHub does not put the repository on the machine for you. checkout is the step that does the git clone of the branch you need.

Аналогія: workflow — це інструкція для орендованого офісу. Job — одна кімната, яку тобі відкривають порожньою і замикають назавжди після роботи. Step — пункт інструкції всередині кімнати. Передати щось із кімнати в кімнату можна лише через кур'єра (артефакт), бо стіни між ними глухі.

An analogy: the workflow is the instruction sheet for a rented office. A job is one room, unlocked for you empty and locked forever once you are done. A step is one line of the instructions inside the room. Getting anything from one room to another takes a courier (an artifact), because the walls between them are solid.

YAML — кістяк workflow, на якому видно всі чотири рівні
YAML — the skeleton of a workflow, with all four levels visible
# .github/workflows/e2e.yml
name: E2E

on:                       # тригер — коли цей файл запускається
  pull_request:
    branches: [main]     # тільки PR, націлені в main
  workflow_dispatch:      # плюс кнопка "Run workflow" вручну

jobs:
  lint:
    runs-on: ubuntu-latest   # образ машини, яку піднімуть
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  e2e:
    needs: lint            # стартує тільки якщо lint зелений
    runs-on: ubuntu-latest
    timeout-minutes: 30    # інакше зависла черга горітиме 6 годин
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Run tests
        run: npx playwright test
# .github/workflows/e2e.yml
name: E2E

on:                       # trigger — when this file runs
  pull_request:
    branches: [main]     # only PRs targeting main
  workflow_dispatch:      # plus a manual "Run workflow" button

jobs:
  lint:
    runs-on: ubuntu-latest   # image of the machine that will be spun up
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  e2e:
    needs: lint            # starts only if lint is green
    runs-on: ubuntu-latest
    timeout-minutes: 30    # otherwise a stuck queue burns for 6 hours
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: Run tests
        run: npx playwright test
🔧 YAML патерни в цьому прикладі Відступи задають вкладеність — табуляція в YAML заборонена, тільки пробіли; [main] — інлайн-список, те саме що список із дефісами на нових рядках; - на початку рядка — елемент списку, тому кожен step починається з дефіса; uses: посилається на чужий репозиторій у форматі власник/репо@тег — тег треба пінити (@v4), інакше версія крокує сама собою; run: — це рядок, який передається в shell, тому && тут звичайний bash, а не YAML.
🔧 YAML patterns in this example Indentation is what creates nesting — tabs are forbidden in YAML, spaces only; [main] — an inline list, the same thing as a list of dashes on separate lines; a - at the start of a line is a list item, which is why every step begins with a dash; uses: points at somebody else's repository as owner/repo@tag — pin the tag (@v4) or the version walks on its own; run: is a string handed to a shell, so && there is ordinary bash, not YAML.
🗣 Як читати вголос Workflow — це YAML-файл у теці крапка-github слеш workflows, і GitHub підхоплює звідти всі файли автоматично, ім'я значення не має. Усередині чотири рівні: сам workflow з блоком on, який каже коли запускатись; потім jobs; усередині job — steps; а крок це або run з shell-командою, або uses з готовою чужою дією. Найважливіше зрозуміти, що job — це окрема віртуальна машина, яку піднімають порожньою і вбивають після завершення. З цього випливає одразу дві речі: jobs за замовчуванням ідуть паралельно і не бачать файлів один одного, а якщо потрібна послідовність — її просиш явно через needs. І з цього ж випливає, чому першим кроком майже завжди стоїть actions/checkout: раннер стартує з порожньою текою, репозиторію на машині просто немає, поки ти його туди не склонуєш.
🗣 How to say it out loud A workflow is a YAML file under dot-github slash workflows, and GitHub picks up everything in that folder automatically, so the file name doesn't matter. Inside there are four levels: the workflow itself with its on block saying when to run, then jobs, then steps inside a job, and a step is either a run with a shell command or a uses with somebody else's ready-made action. The thing that really matters is that a job is a separate virtual machine, brought up empty and killed when it finishes. Two things follow from that immediately: jobs go in parallel by default and can't see each other's files, so if you need an order you ask for it explicitly with needs, and if you need to pass files you do it through artifacts. And it's the same fact that explains why the first step is almost always actions/checkout — the runner starts with an empty folder, the repository simply isn't on the machine until you clone it there.
CI-2 Як виглядає job, який ставить браузери, ганяє Playwright і віддає звіт?What does a job that installs browsers, runs Playwright and hands back the report look like?
CI для QACI for QA Junior ▶

Мінімальний робочий job — це п'ять кроків, і кожен закриває конкретну дірку в тому, що раннер стартує порожнім.

A minimal working job is five steps, and each one closes a specific hole left by the fact that the runner starts empty.

1. checkout — клонує репозиторій. Без нього наступний крок впаде на «немає package.json».

1. checkout — clones the repository. Without it the next step dies on "no package.json".

2. setup-node — ставить потрібну версію Node. На ubuntu-latest Node уже є, але його версія змінюється, коли GitHub оновлює образ. Тобто без цього кроку версія Node у твоєму CI — це не рішення команди, а побічний ефект чужого релізу.

2. setup-node — installs the Node version you want. ubuntu-latest already has Node, but its version changes whenever GitHub refreshes the image. So without this step the Node version in your CI isn't a decision your team made, it's a side effect of somebody else's release.

3. npm ci, не npm install — це не стилістика. npm ci видаляє node_modules і ставить рівно те, що записано в package-lock.json; якщо lock розходиться з package.json, він падає замість того щоб «полагодити». npm install навпаки має право оновити lock — і тоді CI перевірив не той набір версій, який лежить у гілці.

3. npm ci, not npm install — this isn't a matter of style. npm ci wipes node_modules and installs exactly what is written in package-lock.json; if the lock disagrees with package.json it fails instead of "fixing" things. npm install, on the other hand, is allowed to update the lock — and then CI tested a set of versions that isn't the one sitting in the branch.

4. npx playwright install --with-deps — найчастіше пропущений крок. Браузери Playwright — це не npm-пакети: npm ci ставить лише JS-обгортку, а самі бінарники Chromium, Firefox і WebKit качаються окремо в ~/.cache/ms-playwright. Прапорець --with-deps доставляє ще й системні бібліотеки Linux, яких у голому образі раннера немає, — саме через них з'являється класична помилка «Host system is missing dependencies».

4. npx playwright install --with-deps — the most frequently forgotten step. Playwright's browsers are not npm packages: npm ci only installs the JS wrapper, while the Chromium, Firefox and WebKit binaries are downloaded separately into ~/.cache/ms-playwright. The --with-deps flag also brings in the Linux system libraries that a bare runner image doesn't have — they are exactly what produces the classic "Host system is missing dependencies" error.

5. upload-artifact з if: always() — і ось тут ховається головна пастка новачка. За замовчуванням крок виконується, тільки якщо всі попередні зелені. Тести впали → крок вивантаження звіту пропущено → доказів немає саме тоді, коли вони потрібні. if: always() перевертає це: вивантажуй звіт незалежно від результату.

5. upload-artifact with if: always() — and here is where the main beginner's trap hides. By default a step only runs if everything before it was green. The tests failed → the report upload step is skipped → you have no evidence at precisely the moment you need it. if: always() flips that around: upload the report whatever the result.

порожня машина
   ├── checkout                → з'явився код
   ├── setup-node              → зафіксована версія Node
   ├── npm ci                  → node_modules із lock-файлу
   ├── playwright install      → бінарники браузерів + libs
   ├── playwright test         → ← ТУТ job може стати червоним
   └── upload-artifact         → виконається лише з if: always()
empty machine
   ├── checkout                → the code appears
   ├── setup-node              → a pinned Node version
   ├── npm ci                  → node_modules from the lock file
   ├── playwright install      → browser binaries + libs
   ├── playwright test         → ← HERE the job can turn red
   └── upload-artifact         → runs only with if: always()

Ключове: червоний job без вивантаженого звіту — це не результат прогону, це повідомлення «щось сталось». Крок з if: always() коштує три рядки і перетворює його на нормальний баг-репорт.

The key point: a red job with no report uploaded is not a test result, it's a message saying "something happened". A step with if: always() costs three lines and turns it into a proper bug report.

YAML — той самий job цілком
YAML — that same job in full
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install deps
        run: npm ci

      - name: Install browsers
        # --with-deps доставляє системні бібліотеки Linux,
        # без яких Chromium не стартує на голому раннері
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test

      - name: Upload report
        if: always()   # ← без цього звіту не буде саме коли тести впали
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install deps
        run: npm ci

      - name: Install browsers
        # --with-deps installs the Linux system libraries
        # without which Chromium will not start on a bare runner
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test

      - name: Upload report
        if: always()   # ← without this there is no report exactly when the tests failed
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
🔧 YAML / GitHub Actions патерни в цьому прикладі with: — блок вхідних параметрів для uses-кроку, у run-кроку його не буває; if: always() — вираз Actions, а не YAML: always(), success(), failure() і cancelled() — вбудовані функції статусу; name: у кроці впливає лише на підпис у логах, але з ним лог читається як список дій, а не як список команд; timeout-minutes на рівні job рятує від зависань, які інакше з'їдають квоту раннерів.
🔧 YAML / GitHub Actions patterns in this example with: — the input block for a uses step; a run step never has one; if: always() is an Actions expression rather than YAML: always(), success(), failure() and cancelled() are built-in status functions; name: on a step only affects the label in the logs, but with it the log reads as a list of actions instead of a list of commands; timeout-minutes at job level saves you from hangs that would otherwise eat the runner quota.
🗣 Як читати вголос Це п'ять кроків, і кожен закриває конкретну дірку. Спочатку checkout, бо машина стартує з порожньою текою. Потім setup-node, щоб версія Node була рішенням команди, а не побічним ефектом того, коли GitHub оновив образ раннера. Далі npm ci, а не npm install — ci ставить рівно те, що в lock-файлі, і падає якщо lock розійшовся, тоді як install має право його переписати, і тоді CI перевірив не той набір версій, який лежить у гілці. Четвертий крок — npx playwright install with-deps: браузери це не npm-пакети, npm ci ставить тільки JS-обгортку, а бінарники Chromium і WebKit качаються окремо, плюс with-deps доставляє системні бібліотеки Linux, без яких браузер просто не стартує. І останній крок — upload-artifact із if always. Ось це найважливіше: за замовчуванням крок виконується тільки якщо все зелене, тому без if always ти не отримаєш звіт саме в той момент, коли тести впали і звіт потрібен.
🗣 How to say it out loud It's five steps, and each of them closes a specific hole. First checkout, because the machine starts with an empty folder. Then setup-node, so that the Node version is a decision the team made rather than a side effect of when GitHub last refreshed the runner image. Then npm ci rather than npm install — ci installs exactly what's in the lock file and fails if the lock has drifted, whereas install is allowed to rewrite it, and then CI tested a different set of versions from the one in the branch. Step four is npx playwright install with-deps: browsers aren't npm packages, npm ci only gives you the JS wrapper, the Chromium and WebKit binaries download separately, and with-deps also pulls in the Linux system libraries without which the browser just won't start. And the last step is upload-artifact with if always. That's the important one: by default a step only runs when everything before it is green, so without if always you don't get the report at exactly the moment the tests failed and you need it.
CI-3 Що CI має віддати після падіння, щоб не довелось перезапускати прогін заради розслідування?What should CI hand back after a failure so you never have to re-run just to investigate?
CI для QACI for QA Middle ▶

Раннер знищується разом з усім, що на ньому було. Тому питання «чому впало» має рівно один чесний спосіб отримати відповідь: усе потрібне мало бути вивантажене під час прогону. Перезапуск нічого не доводить — падіння могло не повторитись, а якщо повторилось, ти витратила ще п'ятнадцять хвилин на те, що вже було.

The runner is destroyed along with everything that was on it. So "why did it fail" has exactly one honest way to get answered: everything you need had to be uploaded during the run. A re-run proves nothing — the failure might not repeat, and if it does, you have spent another fifteen minutes on something you already had.

Чотири різні речі, які часто називають одним словом «артефакти»:

Four different things that often get called "artifacts" as if they were one:

  • Скріншот — один кадр у момент падіння. Дешевий, відповідає на «що було на екрані», не відповідає на «як ми туди дійшли».
  • Відео — увесь прогін тесту. Показує шлях, але шукати в ньому потрібну секунду руками — довго, і в нього не можна клікнути.
  • HTML-репорт — підсумок: які тести, скільки часу, які помилки. Це навігація по прогону, а не докази по конкретному падінню.
  • Trace — найцінніше і найгірше зрозуміле. Це не лог і не відео, а запис стану: DOM-снапшот до і після кожної дії, мережеві запити, консоль, таймлайн, вихідний рядок коду для кожного кроку.
  • A screenshot — one frame at the moment of failure. Cheap, answers "what was on the screen", says nothing about "how we got there".
  • A video — the whole test run. It shows the path, but hunting for the right second by hand takes ages, and you can't click into it.
  • An HTML report — the summary: which tests, how long, which errors. That is navigation across the run, not evidence about one specific failure.
  • A trace — the most valuable and the least understood. It isn't a log and it isn't a video, it's a recording of state: a DOM snapshot before and after every action, network requests, the console, a timeline, and the source line for each step.

Чому trace змінює розслідування якісно. У trace viewer можна зупинитись на кроці, який упав, і клікнути мишкою по DOM того моменту — відкрити інспектор, подивитись які насправді були класи, чи був елемент перекритий оверлеєм, чи він взагалі існував. Це не картинка про минуле, це минуле, у яке можна залізти. Саме тому «локатор не знайшов елемент» з trace закривається за дві хвилини, а без trace перетворюється на гру в вгадування.

Why a trace changes an investigation qualitatively. In the trace viewer you can stop on the step that failed and click around the DOM as it was at that moment — open the inspector, see what the classes really were, whether the element was covered by an overlay, whether it existed at all. It isn't a picture of the past, it's a past you can climb into. That is why "the locator didn't find the element" is closed in two minutes with a trace, and turns into a guessing game without one.

trace.zip
  ├── таймлайн дій        goto → fill → click → expect ✗
  ├── DOM snapshot        before / action / after  для КОЖНОЇ дії
  ├── network             усі запити з тілами і статусами
  ├── console             усе, що сайт написав у консоль
  └── source              рядок тесту, який виконувався

відкрити:  npx playwright show-trace trace.zip
або просто перетягнути файл на trace.playwright.dev
trace.zip
  ├── action timeline     goto → fill → click → expect ✗
  ├── DOM snapshot        before / action / after  for EVERY action
  ├── network             all requests with bodies and statuses
  ├── console             everything the site wrote to the console
  └── source              the test line that was executing

open:  npx playwright show-trace trace.zip
or just drag the file onto trace.playwright.dev

Чому trace: 'on-first-retry', а не 'on'. Це компроміс, і його треба вміти захистити:

Why trace: 'on-first-retry' rather than 'on'. It's a trade-off, and you should be able to defend it:

  • 'off' — нуль вартості, нуль доказів. Не варіант для CI.
  • 'on' — trace для кожного тесту, включно з тисячею зелених. Прогін важчає, артефакт роздувається до сотень мегабайт, вивантаження саме стає повільним кроком.
  • 'on-first-retry' — перший прогін іде без запису, а якщо тест упав і Playwright ретраїть його, другий прогін уже пишеться. Плата: тест, який падає тільки з першого разу і проходить на ретраї, залишить trace ретраю, а не того падіння. Але саме такий тест і є флейком, і trace ретраю все одно показує оточення.
  • 'retain-on-failure' — пише завжди, зберігає тільки для впалих. Дорожче по CPU, зате ловить перше падіння. Розумний вибір, коли флейки — головний біль команди.
  • 'off' — zero cost, zero evidence. Not an option for CI.
  • 'on' — a trace for every test, including the thousand green ones. The run gets heavier, the artifact swells to hundreds of megabytes, and the upload itself becomes the slow step.
  • 'on-first-retry' — the first run isn't recorded, and if the test fails and Playwright retries it, the second run is. The price: a test that fails only the first time and passes on the retry will leave you the retry's trace, not the failure's. But a test like that is a flake by definition, and the retry's trace still shows you the environment.
  • 'retain-on-failure' — always records, keeps it only for failures. More expensive in CPU, but it catches the first failure. A sensible choice when flakes are the team's main pain.

Правило, яким це легко пояснити на співбесіді: червоний CI без артефакту — це баг-репорт без кроків відтворення. Ми б не прийняли такий тікет від когось іншого; від власного пайплайну теж не варто.

A rule that makes this easy to explain in an interview: red CI with no artifact is a bug report with no steps to reproduce. We wouldn't accept a ticket like that from anyone else; there's no reason to accept it from our own pipeline.

TypeScript — конфіг доказів
TypeScript — the evidence config
// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,   // ретраї тільки в CI
  use: {
    trace: 'on-first-retry',             // запис вмикається на ретраї
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  reporter: [['html', { open: 'never' }], ['github']],
  // 'github' малює анотації прямо в діффі PR — падіння видно
  // у вкладці Files changed, без походу в логи
});
// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,   // retries only in CI
  use: {
    trace: 'on-first-retry',             // recording turns on for the retry
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  reporter: [['html', { open: 'never' }], ['github']],
  // 'github' draws annotations right in the PR diff — failures are visible
  // in the Files changed tab, without digging through logs
});
YAML — крок, який ці докази вивантажує
YAML — the step that uploads that evidence
# один артефакт на весь набір доказів
- name: Upload evidence
  if: failure()        # звіт вивантажуємо always(), сирі докази — лише при падінні
  uses: actions/upload-artifact@v4
  with:
    # номер спроби в імені — інакше ре-ран перезапише докази першого падіння
    name: evidence-${{ github.run_attempt }}
    path: |
      playwright-report/
      test-results/
    retention-days: 7
# one artifact for the whole set of evidence
- name: Upload evidence
  if: failure()        # the report is uploaded always(), raw evidence — only on failure
  uses: actions/upload-artifact@v4
  with:
    # attempt number in the name — otherwise a re-run overwrites the evidence of the first failure
    name: evidence-${{ github.run_attempt }}
    path: |
      playwright-report/
      test-results/
    retention-days: 7
🔧 TypeScript / YAML патерни в цьому прикладі process.env.CI ? 2 : 0 — тернарний оператор; змінна CI виставлена в раннері автоматично, тому це стандартний спосіб розділити локальну і CI-поведінку одним конфігом; reporter приймає масив пар [назва, опції] — репортерів може бути кілька одночасно; path: | — YAML-блок literal scalar, кожен рядок стає окремим шляхом; ${{ github.run_attempt }} — вираз Actions, який підставляє номер спроби і рятує від конфлікту імен артефактів при ре-рані.
🔧 TypeScript / YAML patterns in this example process.env.CI ? 2 : 0 — a ternary; the CI variable is set by the runner automatically, which makes this the standard way to split local and CI behaviour in one config; reporter takes an array of [name, options] pairs — you can have several reporters at once; path: | — a YAML literal scalar block where every line becomes its own path; ${{ github.run_attempt }} — an Actions expression that inserts the attempt number and saves you from artifact name clashes on a re-run.
🗣 Як читати вголос Раннер помирає разом з усім, що на ньому було, тому все потрібне для розслідування має вивантажитись під час прогону — перезапуск не доказ, а ще п'ятнадцять хвилин. Я розрізняю чотири речі. Скріншот — один кадр, каже що було на екрані, але не каже як ми туди прийшли. Відео — весь шлях, але шукати потрібну секунду руками довго. HTML-репорт — це навігація по прогону, підсумок. А trace — найцінніше: не лог і не відео, а запис стану, де на кожну дію є DOM-снапшот до і після, мережа, консоль і рядок коду. У trace viewer можна зупинитись на впалому кроці і буквально клікнути по DOM того моменту, відкрити інспектор і подивитись, чи елемент був перекритий оверлеєм. Тому падіння локатора з трейсом закривається за дві хвилини. Ставлю зазвичай on-first-retry: on пише трейс на кожен зелений тест і роздуває артефакт до сотень мегабайт, off не дає доказів взагалі. Якщо флейки — головний біль команди, беру retain-on-failure, він дорожчий, зате ловить саме перше падіння. І формулювання, яким я це захищаю: червоний CI без артефакту — це баг-репорт без кроків відтворення.
🗣 How to say it out loud The runner dies along with everything that was on it, so anything you need for the investigation has to be uploaded during the run — a re-run isn't evidence, it's another fifteen minutes. I'd separate four things. A screenshot is one frame; it tells you what was on the screen but not how we got there. A video is the whole path, but scrubbing for the right second by hand takes forever. An HTML report is navigation across the run, a summary. And a trace is the valuable one: not a log and not a video but a recording of state, with a DOM snapshot before and after every action, the network, the console, and the line of code. In the trace viewer I can stop on the failed step and literally click around the DOM as it was, open the inspector, and check whether the element was covered by an overlay. That's why a locator failure with a trace is closed in two minutes. I normally set on-first-retry: on writes a trace for every green test and blows the artifact up to hundreds of megabytes, and off gives you no evidence at all. If flakes are the team's main pain I'd go with retain-on-failure — more expensive, but it catches the first failure rather than the retry. And the line I'd defend it with is that red CI without an artifact is a bug report with no steps to reproduce.
CI-4 Що таке matrix і чому вона не завжди прискорює прогін?What is a matrix, and why doesn't it always make the run faster?
CI для QACI for QA Middle ▶

strategy.matrix — це розмноження одного job на декартовий добуток списків. Пишеш один опис, GitHub створює з нього N незалежних job, кожен на своїй машині, кожен зі своїм значенням змінної. Три браузери × чотири шарди = дванадцять job, які стартують одночасно.

strategy.matrix multiplies one job out across the cartesian product of some lists. You write one description, GitHub creates N independent jobs from it, each on its own machine, each with its own value of the variable. Three browsers × four shards = twelve jobs, all starting at once.

Дві принципово різні задачі, які матриця вирішує, і їх варто називати різними словами:

Two fundamentally different problems a matrix solves, and they deserve different words:

  • Покриття — ганяємо ті самі тести в різних умовах: браузер, версія Node, ОС. Загальний час не падає; ми купуємо впевненість.
  • Шардинг — ділимо один набір тестів на частини: --shard=1/4. Ось це і є прискорення: сто хвилин перетворюються на двадцять п'ять.
  • Coverage — running the same tests under different conditions: browser, Node version, OS. Total time doesn't drop; what you buy is confidence.
  • Sharding — splitting one test set into pieces: --shard=1/4. This is the part that actually speeds things up: a hundred minutes becomes twenty-five.

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

Confusing the two is the standard mistake. Putting three browsers in the matrix and expecting the run to be three times faster won't work: it gets three times wider, at the same duration as its longest cell.

fail-fast: false — для тестів майже завжди. За замовчуванням GitHub скасовує решту комірок, щойно впала перша. Для збірки це розумно: якщо не компілюється під Node 18, немає сенсу чекати Node 20. Для тестів — навпаки шкідливо: ти хочеш побачити, чи впало в усіх трьох браузерах (значить, справжній баг) чи лише в WebKit (значить, специфіка рушія). Скасувавши решту, CI забирає саме ту інформацію, заради якої матрицю й заводили.

fail-fast: false — for tests, nearly always. By default GitHub cancels the remaining cells the moment the first one fails. For a build that's sensible: if it doesn't compile under Node 18, there's no point waiting for Node 20. For tests it's actively harmful: you want to see whether it failed in all three browsers (a real bug) or only in WebKit (an engine quirk). By cancelling the rest, CI takes away exactly the information the matrix was set up to give you.

Де матриця стає дорогою. Кожна комірка — окрема машина, тобто окремий npm ci, окреме завантаження браузерів, окремий прогрів кешу. Це фіксований оверхед, помножений на N:

Where a matrix gets expensive. Every cell is a separate machine, which means a separate npm ci, a separate browser download, a separate cache warm-up. That's a fixed overhead multiplied by N:

1 job,  100 тестів
   setup 3 хв ─── тести 20 хв ────────────────  =  23 хв

4 шарди × 25 тестів
   setup 3 хв ─ 5 хв ─┐
   setup 3 хв ─ 5 хв ─┤   усі паралельно      =   8 хв
   setup 3 хв ─ 5 хв ─┤   але оплачено 32 хв
   setup 3 хв ─ 5 хв ─┘   машинного часу

12 комірок (3 браузери × 4 шарди)
   setup 3 хв × 12 = 36 хв оверхеду до першого тесту
   ↑ і якщо вільних раннерів менше 12 — паралелізму теж менше
1 job,  100 tests
   setup 3 min ── tests 20 min ──────────────  =  23 min

4 shards × 25 tests
   setup 3 min ─ 5 min ─┐
   setup 3 min ─ 5 min ─┤   all in parallel     =   8 min
   setup 3 min ─ 5 min ─┤   but 32 min of machine
   setup 3 min ─ 5 min ─┘   time is paid for

12 cells (3 browsers × 4 shards)
   setup 3 min × 12 = 36 min of overhead before the first test
   ↑ and if fewer than 12 runners are free — there is less parallelism too

Практичне судження, яке відрізняє middle від junior: повна матриця браузерів на кожному PR — зазвичай зайва. Chromium на PR ловить переважну більшість регресій і тримає фідбек коротким; повний набір браузерів переїжджає в nightly, де тридцять хвилин нікому не заважають. Матриця — це важіль, і його ставлять туди, де ціна очікування нижча.

The practical judgement that separates a middle from a junior: a full browser matrix on every PR is usually overkill. Chromium on the PR catches the overwhelming majority of regressions and keeps feedback short; the full browser set moves to nightly, where thirty minutes bother nobody. A matrix is a lever, and you put it where waiting costs less.

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

The key point: a matrix doesn't speed anything up — it parallelises. What speeds things up is sharding inside it. Both are paid for with a fixed overhead per cell, which is why "let's add one more dimension" is never free.

YAML — матриця браузер × шард, і збірка одного звіту після неї
YAML — a browser × shard matrix, and building one report out of it afterwards
jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false       # хочемо побачити ВСІ падіння, не перше
      matrix:
        browser: [chromium, firefox, webkit]
        shard: [1, 2]
        exclude:              # webkit одним шардом — тестів під нього мало
          - browser: webkit
            shard: 2
    # підпис job у списку перевірок PR: "e2e (firefox, 2)"
    name: e2e (${{ matrix.browser }}, ${{ matrix.shard }})
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps ${{ matrix.browser }}
        #                        ↑ качаємо один браузер, не всі три
      - run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/2

      - if: always()
        uses: actions/upload-artifact@v4
        with:
          # ім'я мусить бути унікальним на комірку, інакше v4 впаде на конфлікті
          name: blob-${{ matrix.browser }}-${{ matrix.shard }}
          path: blob-report/

  report:
    needs: e2e
    if: always()           # needs + always() = "чекай на всіх, навіть червоних"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: blob-*
          merge-multiple: true
      - run: npx playwright merge-reports --reporter=html ./
        # ← один звіт на всі комірки замість дванадцяти окремих
jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false       # we want to see ALL failures, not the first one
      matrix:
        browser: [chromium, firefox, webkit]
        shard: [1, 2]
        exclude:              # webkit in a single shard — it has few tests
          - browser: webkit
            shard: 2
    # job label in the PR checks list: "e2e (firefox, 2)"
    name: e2e (${{ matrix.browser }}, ${{ matrix.shard }})
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps ${{ matrix.browser }}
        #                        ↑ download one browser, not all three
      - run: npx playwright test --project=${{ matrix.browser }} --shard=${{ matrix.shard }}/2

      - if: always()
        uses: actions/upload-artifact@v4
        with:
          # the name must be unique per cell, otherwise v4 fails on a conflict
          name: blob-${{ matrix.browser }}-${{ matrix.shard }}
          path: blob-report/

  report:
    needs: e2e
    if: always()           # needs + always() = "wait for everyone, even the red ones"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: blob-*
          merge-multiple: true
      - run: npx playwright merge-reports --reporter=html ./
        # ← one report for all cells instead of twelve separate ones
🔧 GitHub Actions патерни в цьому прикладі matrix перемножує списки; include додає окрему комірку або дописує поле в наявні, exclude викидає комбінацію; ${{ matrix.X }} — підстановка виразу, працює і в run, і в name, і в with; needs разом з if: always() — ідіома «зібрати результати навіть після падіння», без always() job-збирач просто пропуститься; upload-artifact@v4 вимагає унікального імені на кожен job — у v3 однакові імена мовчки зливались, у v4 це помилка.
🔧 GitHub Actions patterns in this example matrix multiplies the lists together; include adds an extra cell or an extra field to existing ones, exclude throws a combination out; ${{ matrix.X }} — expression substitution, which works in run, in name and in with; needs together with if: always() is the "collect the results even after a failure" idiom — without always() the collector job is simply skipped; upload-artifact@v4 requires a unique name per job — in v3 identical names merged silently, in v4 that is an error.
🗣 Як читати вголос Matrix — це розмноження одного job на декартовий добуток списків: пишеш один опис, GitHub створює з нього N незалежних job, кожен на своїй машині. Але я б розділила дві задачі, які нею вирішують. Покриття — це ті самі тести в різних умовах, браузери чи версії Node: загальний час не падає, ми купуємо впевненість. Шардинг — це ділення одного набору на частини через прапорець shard, і ось це реально прискорює. Плутати їх — типова помилка: додати три браузери і чекати прискорення втричі не вийде, прогін стане втричі ширшим при тій самій тривалості. Для тестів я майже завжди ставлю fail-fast false, бо за замовчуванням GitHub скасує решту комірок після першого падіння — і забере саме ту інформацію, заради якої матрицю заводили: впало скрізь чи тільки у WebKit. І головне, про що забувають: кожна комірка це окрема машина зі своїм npm ci і своїм завантаженням браузерів, тобто фіксований оверхед помножений на N. Тому повну матрицю браузерів на кожному PR я зазвичай не тримаю — на PR Chromium, повний набір у нічний прогін, де тридцять хвилин нікому не заважають.
🗣 How to say it out loud A matrix multiplies one job out across the cartesian product of some lists: you write one description and GitHub creates N independent jobs from it, each on its own machine. But I'd separate the two problems people solve with it. Coverage is the same tests under different conditions, browsers or Node versions — the total time doesn't drop, what you buy is confidence. Sharding is splitting one set into pieces with the shard flag, and that's the part that genuinely speeds things up. Mixing them up is the usual mistake: adding three browsers and expecting a three-times speed-up won't happen, the run just gets three times wider at the same duration. For tests I almost always set fail-fast to false, because by default GitHub cancels the remaining cells after the first failure — and that takes away exactly the information the matrix was there to give you, namely whether it failed everywhere or only in WebKit. And the thing people forget: every cell is a separate machine with its own npm ci and its own browser download, so it's a fixed overhead multiplied by N. That's why I don't usually keep the full browser matrix on every PR — Chromium on the PR, the full set in the nightly run, where thirty minutes bother nobody.
CI-5 Як кешувати залежності — і як зрозуміти, що червоний білд це кеш, а не код?How do you cache dependencies — and how do you tell a red build is the cache and not the code?
CI для QACI for QA Senior ▶

Кеш у CI — це домовленість: «якщо вхідні дані ті самі, результат теж той самий, тому не рахуймо його вдруге». Уся правильність кешування зводиться до одного питання — чи справді ключ описує всі вхідні дані. Коли він описує не всі, кеш перестає бути оптимізацією і стає прихованим станом, який тече між прогонами. А прихований стан у системі, яку ми тримаємо саме за детермінізм, — це вже не оптимізація, це баг.

A cache in CI is an agreement: "if the inputs are the same, the result is the same, so let's not compute it twice". The whole correctness of caching comes down to one question — does the key really describe all of the inputs. When it describes only some of them, the cache stops being an optimisation and becomes hidden state leaking between runs. And hidden state in a system we keep precisely for its determinism isn't an optimisation any more, it's a bug.

Що кешується в Playwright-проєкті і чому це два різні кеші:

What gets cached in a Playwright project, and why these are two different caches:

  • npm-пакети. Найпростіший випадок: actions/setup-node з cache: 'npm' робить усе сам і ключує по хешу package-lock.json. Тут майже нема як помилитись.
  • Бінарники браузерів у ~/.cache/ms-playwright. Ось де живуть усі проблеми — бо цей кеш доводиться описувати руками, а версія браузера залежить не від lock-файлу цілком, а від версії пакета @playwright/test.
  • npm packages. The easy case: actions/setup-node with cache: 'npm' does everything itself and keys on the hash of package-lock.json. There is almost no way to get this wrong.
  • Browser binaries in ~/.cache/ms-playwright. This is where all the trouble lives — because you have to describe this cache by hand, and the browser version depends not on the lock file as a whole but on the version of the @playwright/test package.

Механіка ключа, і чому restore-keys — заряджена рушниця:

How the key works, and why restore-keys is a loaded gun:

key:          pw-1.55.0-a3f9c2       ← точний збіг
restore-keys: pw-1.55.0-              ← префіксний фолбек
              pw-                     ← ще ширший фолбек

точний збіг       → відновили те саме → крок install майже миттєвий
збіг по префіксу  → відновили ЩОСЬ ІНШЕ, але схоже → ← ось тут біда
жодного збігу     → качаємо з нуля → повільно, але ЧЕСНО
key:          pw-1.55.0-a3f9c2       ← exact match
restore-keys: pw-1.55.0-              ← prefix fallback
              pw-                     ← an even broader fallback

exact match       → restored the same thing → the install step is almost instant
prefix match      → restored SOMETHING ELSE, but similar → ← this is where it goes wrong
no match          → download from scratch → slow, but HONEST

Префіксний фолбек придумали для npm, де він безпечний: відновили трохи застарілу теку, npm ci зверху донесе різницю і приведе стан до lock-файлу. Для бінарників браузерів цієї «доносячої» команди немає. npx playwright install бачить, що тека ~/.cache/ms-playwright непорожня, і за замовчуванням не перекачує те, що вважає наявним. Результат: у пакеті вже Playwright 1.56, а на диску бінарник, який відновився з ключа pw- і належить 1.49. Тести стартують і падають у місцях, які не мають стосунку до змін у PR.

The prefix fallback was invented for npm, where it is safe: you restore a slightly stale folder, and npm ci on top fills in the difference and brings the state up to the lock file. For browser binaries there is no such "fill in the difference" command. npx playwright install sees that ~/.cache/ms-playwright isn't empty and by default won't re-download what it considers present. The result: the package is already on Playwright 1.56, while the binary on disk was restored from the pw- key and belongs to 1.49. The tests start and fail in places that have nothing to do with the changes in the PR.

Симптоми, за якими я підозрюю кеш ще до того, як полізу в код:

The symptoms that make me suspect the cache before I even open the code:

  • Локально чисто, у CI червоно — і в діффі немає нічого, що могло б це пояснити.
  • Впало одночасно на кількох незалежних PR різних людей. Спільного коду в них нема; спільний у них кеш.
  • Ре-ран з очищеним кешем зелений, а звичайний ре-ран червоний. Це майже діагноз.
  • Червоне почалось рівно після PR, який чіпав package.json чи Dockerfile, — тобто змінились вхідні дані, яких у ключі не було.
  • Помилка виглядає як невідповідність версій: відсутній API, «Executable doesn't exist», незрозумілий segfault браузера.
  • Clean locally, red in CI — and there is nothing in the diff that could explain it.
  • It failed at the same time on several independent PRs from different people. They share no code; they do share a cache.
  • A re-run with the cache cleared is green, while an ordinary re-run is red. That is almost a diagnosis on its own.
  • The red started right after a PR that touched package.json or the Dockerfile — meaning an input changed that the key never had in it.
  • The error looks like a version mismatch: a missing API, "Executable doesn't exist", an unexplained browser segfault.

Як це лікується — по спаданню сили:

How it gets fixed, strongest first:

  • Внести в ключ усе, що впливає на вміст: версію пакета, ОС раннера, архітектуру. Ключ має бути довгим і потворним — це нормально.
  • Для браузерів не давати restore-keys взагалі. Промах кешу коштує дві хвилини завантаження; частковий збіг коштує день розслідування.
  • Якщо збігу не було — install все одно має відпрацювати. Крок з if: steps.cache.outputs.cache-hit != 'true' має ставити повний набір, а не покладатись на «щось там уже лежить».
  • Тримати одну кнопку «прогнати без кешу» — workflow_dispatch з булевим інпутом. Вона перетворює гіпотезу «це кеш» з дискусії на п'ятихвилинну перевірку.
  • Put everything that affects the contents into the key: the package version, the runner OS, the architecture. The key is supposed to be long and ugly — that's fine.
  • For browsers, don't give it restore-keys at all. A cache miss costs two minutes of downloading; a partial match costs a day of investigation.
  • If there was no hit, install still has to do its job. The step behind if: steps.cache.outputs.cache-hit != 'true' must install the full set rather than trust that "something is already lying around".
  • Keep one "run without the cache" button — workflow_dispatch with a boolean input. It turns "I think it's the cache" from a debate into a five-minute check.

Правило, яке я формулюю саме так: кеш має впливати тільки на час прогону і ніколи на його результат. Якщо прибирання кешу міняє колір білда — ключ описує не всі вхідні дані, і це треба чинити в ключі, а не ретраями.

The rule, phrased exactly this way: a cache may affect only the time a run takes and never its result. If clearing the cache changes the colour of the build, the key doesn't describe all the inputs, and that gets fixed in the key, not with retries.

YAML — два кеші: npm автоматом, браузери руками і без фолбеку
YAML — two caches: npm automatically, browsers by hand and with no fallback
steps:
  - uses: actions/checkout@v4

  # 1. npm — тут усе робить сама дія, ключ по хешу lock-файлу
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'
  - run: npm ci

  # 2. дістаємо точну версію Playwright — вона і визначає бінарники
  - id: pw
    run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT

  # 3. кеш браузерів. Ключ несе ОС і версію. restore-keys НЕМАЄ —
  #    частковий збіг тут гірший за промах
  - id: cache
    uses: actions/cache@v4
    with:
      path: ~/.cache/ms-playwright
      key: pw-${{ runner.os }}-${{ steps.pw.outputs.v }}

  # 4. збігу не було → ставимо повний набір разом із системними бібліотеками
  - if: steps.cache.outputs.cache-hit != 'true'
    run: npx playwright install --with-deps

  # 5. збіг був → бінарники з кеша, але системні бібліотеки НЕ кешуються:
  #    вони ставляться в /usr/lib самої машини, а машина щоразу нова
  - if: steps.cache.outputs.cache-hit == 'true'
    run: npx playwright install-deps
steps:
  - uses: actions/checkout@v4

  # 1. npm — the action does everything here, key by the lock file hash
  - uses: actions/setup-node@v4
    with:
      node-version: 20
      cache: 'npm'
  - run: npm ci

  # 2. get the exact Playwright version — it defines the binaries
  - id: pw
    run: echo "v=$(node -p "require('@playwright/test/package.json').version")" >> $GITHUB_OUTPUT

  # 3. browser cache. The key carries the OS and the version. There are NO restore-keys —
  #    a partial match here is worse than a miss
  - id: cache
    uses: actions/cache@v4
    with:
      path: ~/.cache/ms-playwright
      key: pw-${{ runner.os }}-${{ steps.pw.outputs.v }}

  # 4. no match → install the full set together with the system libraries
  - if: steps.cache.outputs.cache-hit != 'true'
    run: npx playwright install --with-deps

  # 5. there was a match → binaries from the cache, but system libraries are NOT cached:
  #    they go into /usr/lib of the machine itself, and the machine is new every time
  - if: steps.cache.outputs.cache-hit == 'true'
    run: npx playwright install-deps
🔧 GitHub Actions / Bash патерни в цьому прикладі id: на кроці дає доступ до його результатів через steps.<id>.outputs; echo "k=v" >> $GITHUB_OUTPUT — підтримуваний спосіб віддати значення з run-кроку наступним крокам (старий ::set-output вимкнено); node -p друкує результат виразу, тому require(...) тут читає версію прямо з встановленого пакета, а не з package.json проєкту, де може стояти діапазон ^1.55; runner.os — вбудований контекст, який відрізняє Linux від macOS у ключі; cache-hit повертає рядок 'true', а не булеве значення, тому порівняння саме з рядком.
🔧 GitHub Actions / Bash patterns in this example id: on a step gives you its results through steps.<id>.outputs; echo "k=v" >> $GITHUB_OUTPUT — the supported way to pass a value from a run step to later steps (the old ::set-output is disabled); node -p prints the value of an expression, so require(...) here reads the version straight from the installed package rather than from the project's package.json, which may hold a range like ^1.55; runner.os — a built-in context that tells Linux from macOS inside the key; cache-hit returns the string 'true', not a boolean, which is why the comparison is against a string.
🗣 Як читати вголос Кеш — це домовленість: якщо вхідні дані ті самі, результат теж той самий. Тому вся правильність кешування зводиться до одного питання — чи справді ключ описує всі вхідні дані. Якщо не всі, кеш перестає бути оптимізацією і стає прихованим станом, який тече між прогонами. У Playwright-проєкті кешів два. Пакети — просто, setup-node з cache npm робить усе сам по хешу lock-файлу. А от бінарники браузерів у кеші ms-playwright доводиться описувати руками, і саме там усі проблеми. Головна пастка — restore-keys, префіксний фолбек. Для npm він безпечний, бо npm ci зверху донесе різницю до стану lock-файлу. Для браузерів такої доносячої команди немає: playwright install бачить, що тека непорожня, і не перекачує. І виходить, що в пакеті вже одна версія, а на диску бінарник від старої, яка приїхала по префіксу. Тести падають у місцях, які не мають стосунку до PR. Підозрюю кеш я за симптомами: локально чисто, у CI червоно, і в діффі немає нічого, що це пояснює; впало одночасно в кількох незалежних PR; ре-ран з очищеним кешем зелений, а звичайний червоний. Лікую тим, що вношу в ключ версію пакета, ОС і архітектуру, і для браузерів не даю restore-keys взагалі — промах кеша коштує дві хвилини, частковий збіг коштує день. Правило формулюю так: кеш має впливати на час прогону і ніколи на його результат. Якщо прибирання кеша міняє колір білда, значить ключ неповний, і чинити треба ключ, а не ретраї.
🗣 How to say it out loud A cache is an agreement: if the inputs are the same, the result is the same. So the whole correctness of caching comes down to one question — does the key really describe all the inputs. If it doesn't, the cache stops being an optimisation and becomes hidden state that leaks between runs. In a Playwright project there are two caches. Packages are easy: setup-node with cache npm does it all off the hash of the lock file. The browser binaries under the ms-playwright cache are the ones you have to describe by hand, and that's where all the trouble is. The main trap is restore-keys, the prefix fallback. For npm it's safe, because npm ci on top fills in the difference up to the lock file. For browsers there's no such command: playwright install sees the folder isn't empty and won't re-download. So the package is on one version while the binary on disk came from an older one via the prefix, and the tests fail in places that have nothing to do with the PR. I suspect the cache from the symptoms: clean locally, red in CI, with nothing in the diff to explain it; several independent PRs failing at the same moment; a cleared-cache re-run going green while an ordinary re-run stays red. The fix is to put the package version, the OS and the architecture into the key, and for browsers not to give restore-keys at all — a miss costs two minutes, a partial match costs a day. And I'd phrase the rule like this: a cache may affect how long a run takes and never what it returns. If clearing the cache changes the colour of the build, the key is incomplete, and it's the key you fix, not the retries.
CI-6 Як знайти коміт, після якого тест став флейкі — і чому bisect по флейку працює не так, як по звичайному багу?How do you find the commit that made a test flaky — and why does bisecting a flake work differently from bisecting an ordinary bug?
CI для QACI for QA Senior ▶

git bisect — це бінарний пошук по історії комітів. Ти називаєш коміт, де точно було добре, і коміт, де точно погано; Git ставить робочу теку в середину діапазону і питає результат. Кожна відповідь ріже діапазон навпіл: чотириста комітів — це дев'ять перевірок, а не чотириста.

git bisect is a binary search through commit history. You name a commit where things were definitely good and a commit where they are definitely bad; Git puts the working tree in the middle of the range and asks for the result. Every answer halves the range: four hundred commits is nine checks, not four hundred.

З git bisect run це взагалі перестає бути ручною роботою: ти віддаєш скрипт, який повертає 0 для доброго коміта і ненульовий код для поганого, і йдеш пити каву — Git сам пройде діапазон і назве винний коміт.

With git bisect run it stops being manual work altogether: you hand it a script that returns 0 for a good commit and a non-zero code for a bad one, and go get a coffee — Git walks the range itself and names the guilty commit.

good                                                    bad
  ●───●───●───●───●───●───●───●───●───●───●───●───●───●
                          ↑ крок 1: перевіряємо середину
  ●───●───●───●───●───●───✓ good ───────────────────────
                              ↑ крок 2: середина правої половини
  ────────────────────────●───●───●───✗ bad ────────────
                              ↑ крок 3 …

  14 комітів → 4 перевірки.  400 комітів → 9 перевірок.
good                                                    bad
  ●───●───●───●───●───●───●───●───●───●───●───●───●───●
                          ↑ step 1: check the middle
  ●───●───●───●───●───●───✓ good ───────────────────────
                              ↑ step 2: middle of the right half
  ────────────────────────●───●───●───✗ bad ────────────
                              ↑ step 3 …

  14 commits → 4 checks.  400 commits → 9 checks.

А тепер найважливіше, і саме це відрізняє відповідь senior від переказу документації.

And now the important part, the one that separates a senior's answer from a retelling of the docs.

Bisect побудований на припущенні, якого у флейка немає: що предикат детермінований, тобто коміт або завжди добрий, або завжди поганий. Флейкі тест — це тест, який на одному й тому самому коміті інколи зелений, інколи червоний. Якщо на кожному кроці прогнати його один раз, бінарний пошук отримає випадкові відповіді і впевнено назве коміт, який ні до чого. Найгірше тут не помилка, а те, що вона виглядає як результат: Git друкує хеш, автора і дату, і це дуже переконливо.

Bisect is built on an assumption a flake does not satisfy: that the predicate is deterministic, meaning a commit is either always good or always bad. A flaky test is by definition one that is sometimes green and sometimes red on the very same commit. Run it once per step and the binary search gets random answers and confidently names a commit that had nothing to do with it. The worst part isn't the mistake, it's that the mistake looks like a result: Git prints a hash, an author and a date, and that is very convincing.

Тому перед bisect флейк треба перетворити на детермінований предикат:

So before bisecting you have to turn the flake into a deterministic predicate:

  • Скрипт ганяє тест не один раз, а N — --repeat-each=20. «Погано» = хоч раз упало за N прогонів.
  • N обирається під частоту флейка. Якщо тест падає приблизно в одному прогоні з десяти, двадцять повторів пропустять падіння приблизно в одному випадку з восьми — прийнятно; п'ять повторів пропустять у половині випадків — це вже генератор випадкових чисел.
  • --workers фіксуємо і ставимо таким самим, як у CI: багато флейків — це гонки, які взагалі не відтворюються на одному воркері.
  • --retries=0 обов'язково. Ретраї існують саме щоб ховати флейк — а ми його якраз ловимо.
  • The script runs the test N times rather than once — --repeat-each=20. "Bad" means it failed at least once out of N.
  • N is chosen to match how often the flake fires. If the test fails roughly one run in ten, twenty repeats will miss the failure about one time in eight — acceptable; five repeats will miss it half the time, which is a random number generator.
  • Pin --workers to the same number CI uses: a lot of flakes are races that simply don't reproduce on a single worker.
  • --retries=0, always. Retries exist precisely to hide a flake — and hunting it is the whole point here.

Три деталі, на яких найчастіше спотикаються:

Three details people trip over most often:

  • Код виходу 125 — це «пропусти цей коміт». Потрібен, коли коміт узагалі не збирається або залежності на ньому не встановлюються: без 125 такий коміт зарахується як поганий і зіпсує пошук.
  • Не можна повертати 128 і вище — цими кодами Git позначає власні аварії, і bisect припинить роботу.
  • git bisect reset у кінці. Під час bisect ти в detached HEAD; без reset легко залишитись на середині історії й почати комітити туди.
  • Exit code 125 means "skip this commit". You need it when a commit doesn't build at all or its dependencies won't install: without 125 such a commit counts as bad and poisons the search.
  • Never return 128 or above — Git uses those codes for its own failures, and bisect will stop.
  • git bisect reset at the end. During a bisect you are in detached HEAD; without the reset it is easy to stay in the middle of history and start committing there.

Коли bisect — не той інструмент. Якщо тест був флейкі завжди, ламати нічого й не ламали — bisect не знайде коміт, бо доброго кінця діапазону не існує. Якщо флейк почався не від коду, а від оточення (оновився образ раннера, поїхала зовнішня залежність), історія теж мовчатиме. Швидка перевірка перед тим як витрачати годину: прогнати той самий тест на старому коміті сьогоднішнім CI. Зелено — винен код, шукаємо bisect'ом. Червоно — винне оточення, і шукати треба в іншому місці.

When bisect is the wrong tool. If the test has always been flaky, nothing was broken in the first place — bisect will find no commit, because there is no good end to the range. If the flake came from the environment rather than the code (the runner image was updated, an external dependency drifted), history will stay silent too. A quick check before you burn an hour: run that same test on an old commit with today's CI. Green means the code is to blame, so bisect it. Red means the environment is to blame, and you should be looking somewhere else.

Ключове: bisect не шукає причину — він шукає момент. Він звужує «щось десь зламалось» до одного дифа, який можна прочитати очима. Далі все одно думати доводиться людині.

The key point: bisect doesn't find the cause — it finds the moment. It narrows "something broke somewhere" down to a single diff you can read with your eyes. The thinking after that is still yours to do.

Bash — ручний прохід bisect
Bash — a manual bisect run
git bisect start
git bisect bad                  # поточний коміт — поганий
git bisect good v2.3.0         # тут ще було добре: тег, хеш або HEAD~200

# Git сам перемикає теку в середину діапазону. Перевіряєш і кажеш:
git bisect good   # або
git bisect bad    # або
git bisect skip   # коміт не збирається — не наша провина

git bisect reset                # ОБОВ'ЯЗКОВО: повертає на початкову гілку
git bisect start
git bisect bad                  # the current commit is bad
git bisect good v2.3.0         # it was still good here: a tag, a hash or HEAD~200

# Git switches the working tree to the middle of the range itself. You check and say:
git bisect good   # or
git bisect bad    # or
git bisect skip   # the commit does not build — not our fault

git bisect reset                # MANDATORY: returns you to the original branch
Bash — скрипт, який детермінізує флейк, і автоматичний прохід
Bash — the script that makes a flake deterministic, and the automated run
#!/usr/bin/env bash
# bisect-flaky.sh — дає bisect чесний, повторюваний предикат
set -u

# залежності могли змінитись між комітами діапазону
npm ci --silent || exit 125       # 125 = "пропусти коміт", НЕ "поганий"

# 20 повторів, ретраї вимкнені, воркери як у CI —
# інакше гонка просто не відтворюється
npx playwright test tests/checkout.spec.ts \
  --repeat-each=20 \
  --retries=0 \
  --workers=4 \
  --reporter=line

# exit code Playwright: 0 = всі 20 зелені → коміт добрий
#                       1 = хоч один упав  → коміт поганий
#!/usr/bin/env bash
# bisect-flaky.sh — gives bisect an honest, repeatable predicate
set -u

# dependencies may have changed between commits in the range
npm ci --silent || exit 125       # 125 = "skip the commit", NOT "bad"

# 20 repeats, retries off, workers as in CI —
# otherwise the race simply does not reproduce
npx playwright test tests/checkout.spec.ts \
  --repeat-each=20 \
  --retries=0 \
  --workers=4 \
  --reporter=line

# Playwright exit code: 0 = all 20 green  → the commit is good
#                       1 = at least one failed → the commit is bad
chmod +x bisect-flaky.sh
git bisect start HEAD v2.3.0     # коротко: спершу bad, потім good
git bisect run ./bisect-flaky.sh

# → a3f9c21 is the first bad commit
#   Author: ...   Date: ...   feat: lazy-load cart widget

git bisect reset
git show a3f9c21 --stat            # і ось тепер читаємо дифф очима
chmod +x bisect-flaky.sh
git bisect start HEAD v2.3.0     # short form: bad first, then good
git bisect run ./bisect-flaky.sh

# → a3f9c21 is the first bad commit
#   Author: ...   Date: ...   feat: lazy-load cart widget

git bisect reset
git show a3f9c21 --stat            # and now we read the diff with our own eyes
🔧 Bash / Git патерни в цьому прикладі Коди виходу — це весь інтерфейс між скриптом і git bisect run: 0 добрий, 1..124 поганий, 125 пропустити, 128+ зарезервовано Git; || у bash виконує праву частину лише коли ліва впала — звідси ідіома команда || exit 125; \ у кінці рядка переносить одну довгу команду; set -u валить скрипт на невизначеній змінній замість того щоб мовчки підставити порожній рядок; git bisect start HEAD v2.3.0 — скорочення, де перший аргумент bad, другий good.
🔧 Bash / Git patterns in this example Exit codes are the entire interface between the script and git bisect run: 0 good, 1..124 bad, 125 skip, 128+ reserved by Git; || in bash runs the right-hand side only when the left one failed — hence the idiom command || exit 125; a \ at the end of a line continues one long command; set -u kills the script on an undefined variable instead of quietly substituting an empty string; git bisect start HEAD v2.3.0 — the shorthand where the first argument is bad and the second is good.
🗣 Як читати вголос Bisect — це бінарний пошук по історії: називаєш коміт, де точно було добре, і коміт, де точно погано, і Git ставить теку в середину діапазону та питає результат. Чотириста комітів перетворюються на дев'ять перевірок. З git bisect run це взагалі автоматично: віддаєш скрипт, який повертає нуль для доброго коміта і ненульовий код для поганого. Але з флейком є принципова проблема, і саме її я б підкреслила. Bisect припускає, що предикат детермінований — коміт або завжди добрий, або завжди поганий. Флейк за визначенням недетермінований. Якщо на кожному кроці прогнати тест один раз, бінарний пошук отримає випадкові відповіді і впевнено назве невинний коміт — а виглядатиме це переконливо, з хешем, автором і датою. Тому перед bisect флейк треба зробити детермінованим: скрипт ганяє тест не раз, а разів двадцять через repeat-each, з retries нуль і з тією ж кількістю воркерів, що в CI, бо багато флейків — це гонки, які на одному воркері просто не відтворюються. Поганим вважаємо коміт, якщо впало хоч раз із двадцяти. Ще дві деталі: код виходу 125 означає пропустити коміт — це для випадків, коли він не збирається, без цього він зарахується як поганий; і обов'язковий git bisect reset у кінці, бо під час bisect ти в detached HEAD. І перед усім цим я роблю швидку перевірку: прогнати старий коміт сьогоднішнім CI. Якщо він теж червоний — винен не код, а оточення, і bisect тут не допоможе взагалі.
🗣 How to say it out loud Bisect is a binary search through history: you name a commit where things were definitely good and one where they're definitely bad, and Git puts the tree in the middle of the range and asks for the result. Four hundred commits turn into nine checks. With git bisect run it's automatic — you hand it a script that returns zero for a good commit and non-zero for a bad one. But with a flake there's a fundamental problem, and that's the part I'd stress. Bisect assumes the predicate is deterministic: a commit is either always good or always bad. A flake is non-deterministic by definition. Run the test once per step and the binary search gets random answers and confidently names an innocent commit — and it looks convincing, with a hash, an author and a date. So before bisecting I make the flake deterministic: the script runs the test twenty times with repeat-each, retries at zero, and the same worker count as CI, because a lot of flakes are races that just don't reproduce on one worker. A commit counts as bad if it failed even once out of twenty. Two more details: exit code 125 means skip this commit, for when it doesn't build — without it the commit counts as bad; and git bisect reset at the end, because during a bisect you're in detached HEAD. And before any of that I do a quick check: run an old commit through today's CI. If that's red too, it isn't the code, it's the environment, and bisect won't help at all.
CI-7 Зробила reset --hard не на тій гілці. Що втрачено насправді, а що ні?I ran reset --hard on the wrong branch. What is actually lost, and what isn't?
CI для QACI for QA Senior ▶

Правильна відповідь починається не з команди, а з моделі. Гілка — це не контейнер із комітами. Це файл із одним рядком: хешем одного коміта. Коміти лежать в об'єктній базі .git/objects і зв'язані між собою посиланнями на батьків. Гілка лише вказує, з якого місця починати йти назад.

The right answer starts with the model, not with a command. A branch is not a container of commits. It is a file with one line in it: the hash of one commit. The commits live in the object store under .git/objects and are linked to each other by references to their parents. The branch only says where to start walking back from.

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

Everything else follows from that. git reset --hard deletes no commits — it rewrites one line in a branch file. The commits that "disappeared" haven't gone anywhere: they simply became unreachable, because nothing points at them any more. An unreachable commit stays in the store until the garbage collector takes it, and by default it won't touch anything younger than 90 days.

ДО:
  A ── B ── C ── D ── E   ← feature/checkout (файл із хешем E)

git reset --hard C

ПІСЛЯ:
  A ── B ── C             ← feature/checkout (у файлі тепер хеш C)
              ╲
               D ── E     ← на них НІХТО не показує,
                            але вони лежать у .git/objects

  reflog: ...тут записано, що гілка щойно вказувала на E
BEFORE:
  A ── B ── C ── D ── E   ← feature/checkout (file with hash E)

git reset --hard C

AFTER:
  A ── B ── C             ← feature/checkout (the file now has hash C)
              ╲
               D ── E     ← NOTHING points to them,
                            but they are still in .git/objects

  reflog: ...records that the branch was just pointing to E

Reflog — це журнал переміщень вказівників. Кожного разу, коли HEAD або будь-яка гілка змінює значення — коміт, checkout, merge, rebase, reset, pull, — Git дописує рядок: куди вказувало, куди стало вказувати, якою командою. Це локальний файл, він не пушиться і не тягнеться, у нього ніхто не заглядає — але саме він тримає посилання на «зниклі» коміти, і саме тому вони не потрапляють під збирач сміття.

The reflog is a journal of pointer movements. Every time HEAD or any branch changes value — a commit, a checkout, a merge, a rebase, a reset, a pull — Git appends a line: where it pointed, where it points now, which command did it. It is a local file, it isn't pushed and isn't fetched, nobody ever looks into it — and yet it is what holds references to the "vanished" commits, which is exactly why the garbage collector leaves them alone.

Порядок дій, який я реально виконую:

The steps I actually take:

  • git reflog — знайти рядок безпосередньо перед фатальною командою і взяти звідти хеш.
  • git show <хеш> — переконатись, що це справді той коміт, а не сусідній.
  • git branch rescue <хеш> — створити гілку, а не робити checkout. Гілка — це постійний вказівник, після неї коміт знову досяжний і питання закрите. checkout на хеш дає detached HEAD, тобто ще одну ситуацію, з якої треба виплутуватись.
  • git reflog — find the line immediately before the fatal command and take the hash from it.
  • git show <hash> — make sure it really is that commit and not the one next to it.
  • git branch rescue <hash> — create a branch, don't check the hash out. A branch is a permanent pointer, so after it the commit is reachable again and the question is closed. checkout onto a hash gives you a detached HEAD, which is one more situation to dig yourself out of.

Що reflog не рятує — і це друга половина чесної відповіді:

What the reflog does not save — and this is the other half of an honest answer:

  • Незакомічені зміни у working tree. Їх ніколи не було в об'єктній базі, отже не було чого журналювати. Саме тому --hard небезпечний не втратою комітів, а втратою незбереженої роботи.
  • Файли, які взагалі не додавались. Новий неотрекований файл плюс git clean -fd — це просто видалення файлу з диска, Git тут ні до чого.
  • Чужу роботу. Reflog локальний. Коміт, який колега втратила у себе, у твоєму reflog не з'явиться.
  • Прогон у CI. Раннер робить свіжий clone і живе двадцять хвилин — reflog там порожній за визначенням.
  • Uncommitted changes in the working tree. They were never in the object store, so there was nothing to journal. That is why --hard is dangerous not because it loses commits but because it loses unsaved work.
  • Files that were never added at all. A new untracked file plus git clean -fd is just deleting a file from disk; Git has nothing to do with it.
  • Somebody else's work. The reflog is local. A commit a colleague lost on her machine will never appear in your reflog.
  • A CI run. The runner does a fresh clone and lives twenty minutes — the reflog there is empty by definition.

Detached HEAD — той самий механізм, вигляд збоку. Зазвичай HEAD показує на гілку, гілка на коміт. Після checkout <хеш> HEAD показує на коміт напряму. Комітити в такому стані можна, і це працює — але щойно перемкнешся на гілку, ці коміти стануть недосяжними, бо на них не лишиться жодного імені. Git при цьому чесно попереджає і друкує хеш; його варто читати, а не пролистувати.

Detached HEAD is the same mechanism seen from the side. Normally HEAD points at a branch and the branch points at a commit. After checkout <hash>, HEAD points at the commit directly. You can commit in that state and it works — but the moment you switch to a branch those commits become unreachable, because no name is left on them. Git warns you honestly at that point and prints the hash; that message is worth reading rather than scrolling past.

Ключове формулювання: у Git майже неможливо втратити те, що було закомічене, і дуже легко втратити те, що не було. Тому --hard я роблю або після git stash, або після git commit у чернетку — тридцять секунд, які перетворюють будь-яку помилку на дві команди відкату.

The phrasing that matters: in Git it is almost impossible to lose what has been committed, and very easy to lose what hasn't. So I only run --hard after a git stash or after a throwaway git commit — thirty seconds that turn any mistake into two commands of undo.

Bash — reflog після невдалого reset
Bash — the reflog after a bad reset
git reflog
# a1b2c3d (HEAD -> feature/checkout) HEAD@{0}: reset: moving to HEAD~3
# 9f8e7d6                           HEAD@{1}: commit: fix flaky wait
# 5c4b3a2                           HEAD@{2}: commit: add cart specs
#         ↑ HEAD@{1} — стан ПЕРЕД фатальною командою

git show HEAD@{1}            # спершу подивитись, що це справді воно
git branch rescue HEAD@{1}   # гілка, НЕ checkout: коміт знову досяжний
git log --oneline rescue -5  # переконались — далі merge або cherry-pick
git reflog
# a1b2c3d (HEAD -> feature/checkout) HEAD@{0}: reset: moving to HEAD~3
# 9f8e7d6                           HEAD@{1}: commit: fix flaky wait
# 5c4b3a2                           HEAD@{2}: commit: add cart specs
#         ↑ HEAD@{1} — the state BEFORE the fatal command

git show HEAD@{1}            # first check that this is really it
git branch rescue HEAD@{1}   # a branch, NOT checkout: the commit is reachable again
git log --oneline rescue -5  # confirmed — next, merge or cherry-pick
Bash — відкат rebase, і що робити коли reflog не допоміг
Bash — undoing a rebase, and what to do when the reflog didn't help
# rebase перемішав історію, хочемо стан ДО нього
git reflog feature/checkout   # reflog конкретної гілки, не HEAD
# feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout
# feature/checkout@{1}: commit: update fixtures   ← стан до rebase

git reset --hard feature/checkout@{1}
# ORIG_HEAD робить те саме для ОДНІЄЇ останньої великої операції:
git reset --hard ORIG_HEAD

# коміт є, але в журналі його немає — перебрати базу об'єктів
git fsck --lost-found
# dangling commit 7a6b5c4...

# і профілактика, яка робить усе вище непотрібним:
git stash push -u -m "before the scary thing"
#          ↑ -u бере і неотрековані файли, які reflog не врятує
# the rebase scrambled the history, we want the state BEFORE it
git reflog feature/checkout   # reflog of a specific branch, not HEAD
# feature/checkout@{0}: rebase (finish): refs/heads/feature/checkout
# feature/checkout@{1}: commit: update fixtures   ← the state before the rebase

git reset --hard feature/checkout@{1}
# ORIG_HEAD does the same for the ONE last big operation:
git reset --hard ORIG_HEAD

# the commit exists but is not in the log — scan the object database
git fsck --lost-found
# dangling commit 7a6b5c4...

# and the prevention that makes everything above unnecessary:
git stash push -u -m "before the scary thing"
#          ↑ -u also takes untracked files, which reflog will not save
🔧 Git патерни в цьому прикладі HEAD@{N} — синтаксис reflog: «де HEAD був N записів тому», не плутати з HEAD~N, який іде на N батьків назад по історії; git reflog <гілка> читає журнал конкретної гілки, а не HEAD; ORIG_HEAD — автоматична закладка, яку Git ставить перед merge, rebase і reset; git branch <ім'я> <коміт> створює вказівник, не перемикаючись на нього; git stash push -u — прапорець -u захоплює неотрековані файли, без нього вони лишаються на диску і гинуть від git clean.
🔧 Git patterns in this example HEAD@{N} — reflog syntax: "where HEAD was N entries ago", not to be confused with HEAD~N, which walks N parents back through history; git reflog <branch> reads one branch's journal rather than HEAD's; ORIG_HEAD — the bookmark Git sets automatically before a merge, a rebase and a reset; git branch <name> <commit> creates a pointer without switching to it; git stash push -u — the -u flag takes untracked files too, and without it they stay on disk and die to git clean.
🗣 Як читати вголос Я б почала з моделі, бо з неї одразу випливає відповідь. Гілка в Git — це не контейнер з комітами, це файл з одним рядком, у якому лежить хеш одного коміта. Самі коміти живуть в об'єктній базі і посилаються на батьків. Тому reset --hard нічого не видаляє — він переписує один рядок. Коміти, які нібито зникли, просто стали недосяжними, бо на них більше ніхто не показує, і вони спокійно лежать у базі, поки збирач сміття їх не прибере, а він не чіпає нічого молодшого за дев'яносто днів. Тримає їх reflog — локальний журнал, куди Git дописує кожне переміщення HEAD і гілок. Практично я роблю git reflog, знаходжу рядок безпосередньо перед фатальною командою, перевіряю через git show, що це справді той коміт, і роблю git branch rescue на цей хеш — саме гілку, а не checkout, бо checkout на хеш дає detached HEAD, тобто ще одну проблему. Але друга половина відповіді важливіша: reflog не рятує незакомічені зміни в робочій теці, бо їх ніколи не було в об'єктній базі — і саме тому hard небезпечний не втратою комітів, а втратою незбереженої роботи. Він також не рятує неотрековані файли після git clean і не бачить чужий локальний reflog. Тому в Git майже неможливо втратити те, що закомічене, і дуже легко втратити те, що ні — і перед будь-якою страшною командою я роблю stash з прапорцем -u або коміт у чернетку.
🗣 How to say it out loud I'd start from the model, because the answer falls straight out of it. A branch in Git isn't a container of commits, it's a file with one line holding the hash of one commit. The commits themselves live in the object store and point at their parents. So reset --hard deletes nothing — it rewrites one line. The commits that supposedly disappeared just became unreachable, because nothing points at them, and they sit quietly in the store until the garbage collector takes them, and it won't touch anything younger than ninety days. What holds on to them is the reflog, a local journal where Git records every movement of HEAD and of the branches. In practice I run git reflog, find the line immediately before the fatal command, check with git show that it really is that commit, and do git branch rescue on the hash — a branch, not a checkout, because checking a hash out gives me a detached HEAD, which is one more problem. But the second half of the answer matters more: the reflog doesn't save uncommitted changes in the working tree, because they were never in the object store — and that's why hard is dangerous for the unsaved work rather than for the commits. It also doesn't save untracked files after a git clean, and it can't see anyone else's local reflog. So in Git it's almost impossible to lose what's committed and very easy to lose what isn't, which is why before any scary command I stash with -u or drop a throwaway commit.
CI-8 Тест зелений локально і червоний у CI. Як ти звужуєш це до причини замість того щоб перезапустити?The test is green locally and red in CI. How do you narrow that down to a cause instead of hitting re-run?
CI для QACI for QA Senior ▶

Це не одне явище, а шість різних, у яких збігся лише симптом. Тому перше, що я роблю, — не перезапускаю, а класифікую. «Зелений локально, червоний у CI» завжди означає одне: між двома середовищами є різниця, яку тест мовчки вважав константою. Далі — просто перебрати, які саме різниці бувають.

This isn't one phenomenon, it's six different ones that happen to share a symptom. So the first thing I do isn't a re-run, it's a classification. "Green locally, red in CI" always means one thing: there is a difference between the two environments that the test silently treated as a constant. After that it's just a matter of going through which differences exist.

Шість джерел різниці, приблизно за спаданням частоти:

Six sources of difference, roughly in order of how often they bite:

  • Швидкість і паралелізм. Раннер повільніший за ноутбук і ділить CPU з сусідами, а воркерів на ньому зазвичай більше. Тест, який локально встигав, у CI не встигає — і виявляється, що чекав він не на подію, а на час. Ознака: падає на першому expect після навігації, помилка по таймауту.
  • Стан даних. Локальна база носить твої дані з минулого тижня. CI піднімає порожню. Тест, який мовчки спирався на наявного користувача чи товар у кошику, локально не помітить цього ніколи. Ознака: падає на першому кроці, елемента просто немає.
  • Оточення. На раннері UTC, англійська локаль, headless, інший розмір вікна, немає системних шрифтів. Звідси падають перевірки дат, форматів чисел, а візуальні тести дають дифф на антиаліасингу тексту.
  • Порядок і ізоляція. Локально ти ганяєш один файл. CI ганяє всі, паралельно, у порядку, який залежить від шардингу. Тест, який залишає по собі сміття або залежить від сусіда, тут і вилазить. Швидка перевірка: локально прогнати весь набір із тією ж кількістю воркерів.
  • Версії і кеш. Локально стоїть те, що ти колись поставила; CI ставить те, що в lock-файлі, — і ще може підтягнути кеш зі старим ключем.
  • Мережа. Зовнішній сервіс, до якого твій ноутбук ходить, а раннер ні: закритий фаєрволом, ловить rate limit від спільної IP-адреси, відповідає повільніше.
  • Speed and parallelism. The runner is slower than your laptop and shares CPU with its neighbours, and it usually runs more workers. A test that made it in time locally doesn't in CI — and it turns out it was waiting on a duration rather than on an event. The tell: it fails on the first expect after a navigation, with a timeout error.
  • Data state. Your local database is carrying your data from last week. CI brings up an empty one. A test that quietly relied on an existing user or an item in the cart will never notice that locally. The tell: it fails on the very first step, the element simply isn't there.
  • Environment. The runner is UTC, English locale, headless, a different window size, no system fonts. That is what breaks date and number-format assertions, and what makes visual tests diff on text antialiasing.
  • Order and isolation. Locally you run one file. CI runs all of them, in parallel, in an order that depends on sharding. A test that leaves rubbish behind or depends on its neighbour surfaces right here. Quick check: run the whole suite locally with the same worker count.
  • Versions and cache. Locally you have whatever you installed at some point; CI installs what's in the lock file — and may also pull in a cache under a stale key.
  • Network. An external service your laptop can reach and the runner can't: blocked by a firewall, rate-limited on a shared IP address, or simply slower to answer.
червоно в CI, зелено локально
        │
        ├─ впало в УСІХ комірках матриці?
        │     так → детермінована різниця середовищ (дані, версії, мережа)
        │     ні  → різниця конкретного браузера / шарда
        │
        ├─ ре-ран того ж SHA зелений?
        │     так → недетермінізм: гонка, порядок, спільний стан
        │     ні  → відтворюється → знайдемо
        │
        ├─ старий SHA сьогоднішнім CI зелений?
        │     так → винен код у діффі  → git bisect
        │     ні  → винне оточення     → образ раннера, кеш, зовнішній сервіс
        │
        └─ і тільки після цього лізу в код тесту
red in CI, green locally
        │
        ├─ failed in ALL matrix cells?
        │     yes → a deterministic environment difference (data, versions, network)
        │     no  → a difference in a specific browser / shard
        │
        ├─ is a re-run of the same SHA green?
        │     yes → non-determinism: a race, ordering, shared state
        │     no  → it reproduces → we will find it
        │
        ├─ is an old SHA green on today's CI?
        │     yes → the code in the diff is to blame  → git bisect
        │     no  → the environment is to blame       → runner image, cache, external service
        │
        └─ and only after that do I dig into the test code

Метод, а не здогадки — три кроки в такому порядку:

A method rather than guesswork — three steps, in this order:

  • Спочатку артефакт, не гіпотеза. Скачати trace впалого прогону і подивитись DOM того моменту. Половина розслідувань закінчується тут: видно, що елемент був, але перекритий баннером кукі, якого локально немає, бо ти його закрила три місяці тому.
  • Потім відтворити CI локально, а не навпаки. Той самий образ (mcr.microsoft.com/playwright тієї ж версії), TZ=UTC, та сама кількість воркерів, увесь набір, а не один файл. Коли падіння відтворилось на своїй машині, задача перестала бути детективною.
  • Відрізнити «завжди в CI» від «іноді». --repeat-each з --retries=0. Це різні класи проблем: перше — різниця середовищ, друге — недетермінізм у самому тесті, і лікуються вони протилежно.
  • The artifact first, not the hypothesis. Download the trace of the failed run and look at the DOM from that moment. Half of these investigations end right here: you can see the element was there but covered by a cookie banner you don't have locally, because you dismissed it three months ago.
  • Then reproduce CI locally, not the other way round. The same image (mcr.microsoft.com/playwright at the same version), TZ=UTC, the same worker count, the whole suite rather than one file. Once the failure reproduces on your own machine it has stopped being detective work.
  • Tell "always in CI" from "sometimes". --repeat-each with --retries=0. These are different classes of problem: the first is an environment difference, the second is non-determinism inside the test itself, and they are fixed in opposite ways.

І позиція щодо ре-ранів, яку я вважаю за потрібне озвучувати вголос. Перезапуск до зеленого — це не полагодження, це навчання команди не вірити CI. Через місяць такої практики червоний білд перестає когось зупиняти, і одного дня разом із флейком проїжджає справжня регресія. Ретраї в конфізі — інструмент нормальний, але вони мають бути видимими: тест, що пройшов з ретраю, — це не «зелений», це «флейкі», і на нього заводиться тікет. Якщо полагодити зараз неможливо, тест іде в карантин явно — окремим проєктом або test.fixme з посиланням на тікет, — а не залишається мовчки ретраїтись назавжди.

And a position on re-runs that I think is worth saying out loud. Re-running until green isn't a fix, it's teaching the team not to trust CI. A month of that and a red build stops stopping anyone, and one day a real regression rides through alongside the flake. Retries in the config are a fine tool, but they have to be visible: a test that passed on a retry isn't "green", it's "flaky", and it gets a ticket. If it can't be fixed right now, the test goes into quarantine explicitly — a separate project or a test.fixme with a link to the ticket — instead of silently retrying forever.

Ключове: «у CI інакше» — це не пояснення, а список із шести пунктів, який перебирається за півгодини. Ре-ран не входить у цей список — він лише відкладає ту саму роботу на наступний тиждень, коли контексту вже не буде.

The key point: "it's different in CI" isn't an explanation, it's a list of six items you can walk in half an hour. A re-run isn't on that list — it only postpones the same work until next week, when the context will be gone.

Bash — відтворити CI на своїй машині замість гри у вгадування
Bash — reproduce CI on your own machine instead of playing guess-the-cause
# той самий образ, що в CI: та сама версія браузерів, шрифтів і libs
# (версія в тегу мусить збігатися з версією @playwright/test)
docker run --rm -it \
  -v "$PWD":/work -w /work \
  -e TZ=UTC -e LANG=en_US.UTF-8 -e CI=1 \
  mcr.microsoft.com/playwright:v1.55.0-noble \
  "npm ci && npx playwright test --workers=4 --retries=0"

# -e CI=1 важливе: конфіг часто читає process.env.CI
# і вмикає інші retries, reporters і headless
# the same image as in CI: the same browser, font and lib versions
# (the version in the tag must match the @playwright/test version)
docker run --rm -it \
  -v "$PWD":/work -w /work \
  -e TZ=UTC -e LANG=en_US.UTF-8 -e CI=1 \
  mcr.microsoft.com/playwright:v1.55.0-noble \
  "npm ci && npx playwright test --workers=4 --retries=0"

# -e CI=1 matters: the config often reads process.env.CI
# and enables different retries, reporters and headless
Bash — відрізнити різницю середовищ від недетермінізму в самому тесті
Bash — tell an environment difference from non-determinism inside the test
# 30 прогонів без ретраїв
npx playwright test tests/checkout.spec.ts \
  --repeat-each=30 --retries=0 --workers=4
# 30/30 зелено локально, а в CI червоно → різниця середовищ
# 27/30 зелено                          → недетермінізм у самому тесті

# ізоляція: чи тест виживає без сусідів по прогону
npx playwright test --grep "@checkout" --workers=1
npx playwright test --grep "@checkout" --workers=4
# різниця між цими двома = спільний стан або гонка
# 30 runs without retries
npx playwright test tests/checkout.spec.ts \
  --repeat-each=30 --retries=0 --workers=4
# 30/30 green locally, but red in CI  → an environment difference
# 27/30 green                          → non-determinism in the test itself

# isolation: does the test survive without its run neighbours
npx playwright test --grep "@checkout" --workers=1
npx playwright test --grep "@checkout" --workers=4
# the difference between these two = shared state or a race
TypeScript — карантин замість мовчазного ретраю
TypeScript — quarantine instead of a silent retry
test.fixme(
  'checkout: promo code applies after cart refresh',
  // flaky у CI з 12.09, гонка між refresh кошика і застосуванням промокоду
  // тікет QAQ-482, відповідальна — команда payments
  async ({ page }) => { /* ... */ },
);
test.fixme(
  'checkout: promo code applies after cart refresh',
  // flaky in CI since 12.09, race between the cart refresh and applying the promo code
  // ticket QAQ-482, owner — the payments team
  async ({ page }) => { /* ... */ },
);
🔧 Docker / Bash / Playwright патерни в цьому прикладі -v "$PWD":/work -w /work — монтуємо поточну теку в контейнер і робимо її робочою, тому код не копіюється всередину образу; -e ЗМІННА=значення передає змінну оточення — саме так відтворюються TZ, локаль і CI; --rm прибирає контейнер після виходу, щоб не накопичувати сміття; test.fixme на відміну від test.skip позначає тест як «знаємо, що зламаний, чинимо» і не дає йому тихо загубитись у звіті; коментар над fixme з датою і номером тікета — це те, що відрізняє карантин від приховування.
🔧 Docker / Bash / Playwright patterns in this example -v "$PWD":/work -w /work — mount the current folder into the container and make it the working directory, so the code isn't copied into the image; -e VAR=value passes an environment variable — that is exactly how you reproduce TZ, the locale and CI; --rm removes the container on exit so they don't pile up; test.fixme, unlike test.skip, marks a test as "we know it's broken, we're fixing it" and keeps it from quietly getting lost in the report; the comment above the fixme with a date and a ticket number is what makes it quarantine rather than concealment.
🗣 Як читати вголос Я не перезапускаю, я класифікую, бо це не одне явище, а шість, у яких збігся симптом. «Зелений локально, червоний у CI» завжди означає, що між середовищами є різниця, яку тест мовчки вважав константою. Різниць буває шість: швидкість і паралелізм, бо раннер повільніший і воркерів більше, і тоді видно, що тест чекав на час, а не на подію; стан даних, бо локальна база носить мої дані, а CI піднімає порожню; оточення — UTC, англійська локаль, headless, немає шрифтів; порядок і ізоляція, бо локально я ганяю один файл, а CI ганяє всі паралельно; версії і кеш; і мережа, бо зовнішній сервіс може бути закритий для раннера або ловити rate limit. Метод у мене з трьох кроків і саме в такому порядку. Спочатку артефакт, а не гіпотеза: скачати trace і подивитись DOM того моменту — половина розслідувань закінчується тут, видно, наприклад, що елемент перекритий баннером кукі, якого локально немає. Потім відтворити CI локально, а не навпаки: той самий докер-образ Playwright, TZ UTC, та сама кількість воркерів, увесь набір. Коли падіння відтворилось на моїй машині, це вже не детектив. І третє — repeat-each з retries нуль, щоб відрізнити «завжди в CI» від «іноді»: це різні класи проблем і лікуються протилежно. І окремо я озвучую позицію щодо ре-ранів: перезапуск до зеленого — це не полагодження, це навчання команди не вірити CI, і одного дня разом із флейком проїде справжня регресія. Ретраї нормальні, але вони мають бути видимими — тест, що пройшов з ретраю, це не зелений, це флейкі, і на нього заводиться тікет. Якщо полагодити зараз неможливо, тест іде в карантин явно, через fixme з посиланням на тікет, а не мовчки ретраїться назавжди.
🗣 How to say it out loud I don't re-run, I classify, because this isn't one phenomenon, it's six that happen to share a symptom. "Green locally, red in CI" always means there's a difference between the environments that the test silently treated as a constant. There are six of those differences: speed and parallelism, because the runner is slower and runs more workers, which exposes a test that was waiting on a duration rather than an event; data state, because my local database carries my data while CI brings up an empty one; environment — UTC, English locale, headless, no fonts; order and isolation, because locally I run one file and CI runs everything in parallel; versions and cache; and the network, because an external service may be closed to the runner or rate-limited. My method is three steps and the order matters. The artifact first, not the hypothesis: download the trace and look at the DOM from that moment — half of these end right here, when you see the element was covered by a cookie banner you don't have locally. Then reproduce CI locally rather than the other way round: the same Playwright docker image, TZ set to UTC, the same worker count, the whole suite. Once it reproduces on my machine it isn't detective work any more. And third, repeat-each with retries at zero, to tell "always in CI" from "sometimes" — those are different classes of problem and they're fixed in opposite ways. I'd also say out loud where I stand on re-runs: re-running until green isn't a fix, it teaches the team not to trust CI, and one day a real regression rides through with the flake. Retries are fine but they have to be visible — a test that passed on a retry isn't green, it's flaky, and it gets a ticket. And if it can't be fixed now, it goes into quarantine explicitly, through fixme with a link to the ticket, rather than retrying silently forever.