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.
робоча директорія 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 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 означає одне й те саме — «працюй з індексом, а не з диском».
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 понад півтори сотні команд. У щоденній роботі автоматизатора живих із них приблизно вісім, і ще десяток — «коли щось пішло не так». Решту можна чесно гуглити все життя, і це нікого не бентежить.
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.
$ 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
$ 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-файл.
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.
Правило одне, і воно вирішує 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 frompackage-lock.jsonwith one command.test-results/— traces, video, failure screenshots. Output of a run.playwright-report/,blob-report/,allure-results/— reports..envand 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.
# --- залежності ---
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
$ # .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 — компактний вивід у дві колонки.
.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.
Зазвичай 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/checkoutchecks out a specific SHA by default, so in CI you are always in detached HEAD. That's why scripts that dogit push HEAD:main, or read the current branch name fromgit 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.
НОРМАЛЬНО — 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
$ 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, включно з тими, куди вже нічого не вказує.
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.
Каталог .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,rebaseandcherry-pickdon'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 statusis 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.
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.
$ 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: звичайний файл і каталог.
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 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.onlyordescribe.onlythat 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:
baseURLpointed atlocalhost,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 strayconsole.log, a commented-outexpectyou 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 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
$ # дешевий: подивитись перед комітом
$ 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 на падіння прогону — це та сама перевірка, тільки автоматична.
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.
Тут та сама трійка з першої картки — 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.
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.
$ 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
$ # оборотно: секунда роботи, і незакомічене в безпеці
$ 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, права робоча директорія.
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.
Ідентифікатор коміта — це не порядковий номер і не випадковий рядок. Це хеш від вмісту самого об'єкта. А у вміст коміта входить більше, ніж здається: кореневе дерево, список батьків, автор із датою, коммітер із власною датою і повідомлення.
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чиamendpush вимагає 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
rebaseor anamend, 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.--forcewill simply wipe their work in silence. cherry-pickdoesn'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-maingets overwritten by the next push;report-a1b2c3ddoesn'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.
$ 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 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).
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).
Найпоширеніша хибна картинка в голові — що гілка це копія проєкту. Копії немає. Гілка — це маленький файл у .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 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
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 switch -c <назва> — створити гілку від поточного коміту й одразу на неї перейти; git branch --show-current — дізнатись, де ти; git log --oneline --graph --all — побачити форму історії, а не список рядків; .git/refs/heads/ — каталог, де гілки лежать буквально як файли.
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.
Щоденний цикл у тест-проєкті короткий і завжди однаковий: перейти на 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 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
СТАРТ ВІД СВІЖОГО 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 pull перед switch -c — стартувати від свіжого; push -u — прив'язати локальну гілку до віддаленої один раз; git fetch + git merge origin/main — оновити гілку без сюрпризів; branch -d (мала літера — відмовиться видаляти незмержене) проти -D (велика — видалить мовчки); fetch --prune — прибрати посилання на віддалені гілки, яких уже нема. Назва гілки — <тип>/<номер задачі>-<слаг>, як у решті репозиторію.
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.
Конфлікт — це не помилка й не поломка. Це 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 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 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
merge.conflictStyle = zdiff3 — показувати спільну основу поруч із двома версіями; git diff під час конфлікту показує лише конфліктні ділянки; git add — це і є «я розв'язав цей файл»; --continue / --abort — дві однакові пари для merge й rebase; git rerere (reuse recorded resolution) — запам'ятовує розв'язання й підставляє його, коли той самий конфлікт трапляється вдруге, що рятує на довгому rebase.
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.
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 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}
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.
stash push -m замість голого git stash — щоб запис мав назву; -u (untracked) проти -a (плюс ігноровані); stash show -p — переглянути вміст без розпакування; pop (взяти й прибрати) проти apply (взяти й лишити) — apply безпечніший, коли не певен; git stash list — єдине місце, де стеш узагалі видно.
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.
На співбесіді за це питання зазвичай малюють схеми. Схеми не цікаві — цікаво, що кожна стратегія робить із тестуванням: де живе регресія, проти чого вона біжить і скільки разів доводиться перевіряти той самий фікс.
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.
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
<тип>/<issue>-<слаг> — один PR, нічого прямо в main. Стратегія тримається не домовленістю, а налаштуванням: branch protection на main плюс обов'язкові статуси перевірок. Домовленість забувають у п'ятницю ввечері, налаштування — ні.
<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.
Обидві команди розв'язують одну задачу — «моя гілка відстала від 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
mainis 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.
БУЛО:
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.
// гілка ще нікому не віддана — причісуємо перед 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 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 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.
Перше, що варто сказати вголос: 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 pullfirst, thengit 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_requestevent 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.
# 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
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 switch -c — сучасна заміна checkout -b саме для гілок; push -u — задає upstream, після нього достатньо голого git push; gh — офіційний CLI GitHub, робить те саме, що кнопки в браузері; Closes #N — ключове слово автозакриття issue; merge-base — спільний предок двох гілок, від нього рахується діф.
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.
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.
# повна форма
<тип>(<область>)!: <короткий опис у наказовому способі>
▲
└── «!» = ламає сумісність
<тіло: навіщо, а не що — «що» видно з діфа>
<футер: 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
# усі виправлення в 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 log --grep — пошук регуляркою по тексту коміта (не по коду; для коду це -S); ^fix(auth) — якір початку рядка, щоб не ловити згадки в тілі; --format='%h %an %s' — короткий хеш, автор, тема; кілька -m поспіль дають заголовок, тіло і футер окремими абзацами.
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.
Для 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.
# 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
твій клон репозиторію
│
├── 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 fetch origin src:dst — refspec: забрати віддалений реф і одразу створити з нього локальну гілку; switch --detach — стати на коміт без гілки (detached HEAD, для «подивитись і піти»); main...pr-42 — три крапки, діф від merge-base, дві крапки дали б інше; --repeat-each — прогін тесту N разів, штатний спосіб ловити флейк.
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.
Тег — це іменований вказівник на конкретний коміт, який, на відміну від гілки, не рухається. Гілка повзе вперед з кожним комітом; тег як прибили, так і стоїть.
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.
# 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
# .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 tag -a — annotated-тег як окремий об'єкт у базі Git; git describe --tags — «найближчий тег плюс скільки комітів після нього», штатний спосіб отримати версію рядком; --always — не падати, якщо тегів нема; gh release create — реліз GitHub поверх наявного тега; fetch-depth: 0 у actions/checkout — повна історія замість shallow-клону.
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.
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.
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
# було — і саме "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
CODEOWNERS — файл, що прив'язує шляхи до обов'язкових рев'юерів; gh api — прямий доступ до REST API GitHub із консолі, коли треба побачити конфіг, а не клікати по вкладках.
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.
У кнопки 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.
гілка: 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
# тест почав падати десь за останні два тижні. Де саме?
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 bisect run <команда> — автоматичний половинний пошук, «погано» визначається ненульовим кодом виходу; git branch --merged після squash не покаже гілку, бо її коміти в main так і не потрапили; «Require linear history» у branch protection — парна налаштування до squash або rebase.
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.
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
mainand it's needed inrelease/3.6, which is still supported. You can't mergemaininto the release branch — forty unrelated commits would come with it. So you take one. - Rescuing work from the wrong branch. You made commits on
maininstead 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.
# фікс уже в 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
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
cherry-pick -x — записує вихідний хеш у повідомлення (роби це для всіх бекпортів); A..B — діапазон комітів, ліва межа не включається; --continue / --abort — вихід із конфлікту, звичайний git commit посеред cherry-pick ламає стан; git cherry -v — які коміти однієї гілки ще не мають еквівалента в іншій, порівнюючи діфи, а не хеші.
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.
Це називають семантичним конфліктом, і питання це майже завжди відділяє тих, хто 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()tosignIn()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 + Bbranch 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
mainafter every merge. It prevents nothing, but it gives an honest answer to "ismaingreen 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.
час ──────────────────────────────────────────────────────────►
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
# .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
on: push: branches: [main] — воркфлоу здоров'я гілки, окремо від pull_request; gh pr update-branch — оновити гілку PR до вершини base без ручного rebase.
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.
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.
runis a shell command.usesis 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.
# .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
[main] — інлайн-список, те саме що список із дефісами на нових рядках; - на початку рядка — елемент списку, тому кожен step починається з дефіса; uses: посилається на чужий репозиторій у форматі власник/репо@тег — тег треба пінити (@v4), інакше версія крокує сама собою; run: — це рядок, який передається в shell, тому && тут звичайний bash, а не YAML.
[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.
Мінімальний робочий 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.
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
with: — блок вхідних параметрів для uses-кроку, у run-кроку його не буває; if: always() — вираз Actions, а не YAML: always(), success(), failure() і cancelled() — вбудовані функції статусу; name: у кроці впливає лише на підпис у логах, але з ним лог читається як список дій, а не як список команд; timeout-minutes на рівні job рятує від зависань, які інакше з'їдають квоту раннерів.
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.
Раннер знищується разом з усім, що на ньому було. Тому питання «чому впало» має рівно один чесний спосіб отримати відповідь: усе потрібне мало бути вивантажене під час прогону. Перезапуск нічого не доводить — падіння могло не повторитись, а якщо повторилось, ти витратила ще п'ятнадцять хвилин на те, що вже було.
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.
// 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
});
# один артефакт на весь набір доказів
- 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
process.env.CI ? 2 : 0 — тернарний оператор; змінна CI виставлена в раннері автоматично, тому це стандартний спосіб розділити локальну і CI-поведінку одним конфігом; reporter приймає масив пар [назва, опції] — репортерів може бути кілька одночасно; path: | — YAML-блок literal scalar, кожен рядок стає окремим шляхом; ${{ github.run_attempt }} — вираз Actions, який підставляє номер спроби і рятує від конфлікту імен артефактів при ре-рані.
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.
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.
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
matrix перемножує списки; include додає окрему комірку або дописує поле в наявні, exclude викидає комбінацію; ${{ matrix.X }} — підстановка виразу, працює і в run, і в name, і в with; needs разом з if: always() — ідіома «зібрати результати навіть після падіння», без always() job-збирач просто пропуститься; upload-artifact@v4 вимагає унікального імені на кожен job — у v3 однакові імена мовчки зливались, у v4 це помилка.
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.
Кеш у 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-nodewithcache: 'npm'does everything itself and keys on the hash ofpackage-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/testpackage.
Механіка ключа, і чому 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.jsonor 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-keysat all. A cache miss costs two minutes of downloading; a partial match costs a day of investigation. - If there was no hit,
installstill has to do its job. The step behindif: 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_dispatchwith 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.
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
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', а не булеве значення, тому порівняння саме з рядком.
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.
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
--workersto 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 resetat 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.
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
#!/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
git bisect run: 0 добрий, 1..124 поганий, 125 пропустити, 128+ зарезервовано Git; || у bash виконує праву частину лише коли ліва впала — звідси ідіома команда || exit 125; \ у кінці рядка переносить одну довгу команду; set -u валить скрипт на невизначеній змінній замість того щоб мовчки підставити порожній рядок; git bisect start HEAD v2.3.0 — скорочення, де перший аргумент bad, другий good.
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.
Правильна відповідь починається не з команди, а з моделі. Гілка — це не контейнер із комітами. Це файл із одним рядком: хешем одного коміта. Коміти лежать в об'єктній базі .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.checkoutonto 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
--hardis 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 -fdis 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.
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
# 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
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.
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.
Це не одне явище, а шість різних, у яких збігся лише симптом. Тому перше, що я роблю, — не перезапускаю, а класифікую. «Зелений локально, червоний у 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
expectafter 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/playwrightat 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-eachwith--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.
# той самий образ, що в 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
# 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
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 }) => { /* ... */ },
);
-v "$PWD":/work -w /work — монтуємо поточну теку в контейнер і робимо її робочою, тому код не копіюється всередину образу; -e ЗМІННА=значення передає змінну оточення — саме так відтворюються TZ, локаль і CI; --rm прибирає контейнер після виходу, щоб не накопичувати сміття; test.fixme на відміну від test.skip позначає тест як «знаємо, що зламаний, чинимо» і не дає йому тихо загубитись у звіті; коментар над fixme з датою і номером тікета — це те, що відрізняє карантин від приховування.
-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.