Знайома ситуація. Ви два тижні робили експорт витрат у CSV. Довелося додати новий endpoint, витягнути форматування в окремий сервіс, підправити міграцію, дописати background job і заодно виправити баг у пагінації, бо без нього експорт віддавав дублікати.
Виходить pull request на 47 файлів і +2100/-680 рядків. Ви відкриваєте його в п'ятницю. У понеділок під ним один коментар: "LGTM". У середу хтось нарешті читає уважно, знаходить проблему в міграції, і ви переробляєте половину. Ще через два дні PR мерджиться, і ніхто в команді не має жодного уявлення, що саме туди потрапило.
Справа не в лінощах розробника. Робота була однією логічною послідовністю, а інструмент запропонував запакувати її в один атомарний блок.
Stacked PRs - це спроба перестати вибирати між "великий PR, який ніхто не читає" і "чекаю три дні на review, поки нічого не роблю".
До літа 2026 стек треба було збирати руками або сторонніми інструментами. 30 липня GitHub додав стеки в public preview, і тепер розуміє стек як окрему сутність. Половина порад із блогів останніх п'яти років після цього просто застаріла.
Що таке stack
Stacked PR - це ланцюжок гілок, де кожна наступна відгалужена від попередньої, а не від main. Кожна гілка отримує свій pull request, і base branch цього PR - не main, а гілка нижче в стеку.
gitGraph
commit id: "main"
branch feat/expense-pagination-fix
commit id: "fix pagination"
branch feat/expense-formatter
commit id: "CsvFormatter"
branch feat/expense-endpoint
commit id: "GET /export"
branch feat/expense-job
commit id: "nightly job"
Чотири PR замість одного:
| PR | Гілка | Base | Розмір |
|---|---|---|---|
| #101 | feat/expense-pagination-fix |
main |
+30/-12 |
| #102 | feat/expense-formatter |
feat/expense-pagination-fix |
+180/-4 |
| #103 | feat/expense-endpoint |
feat/expense-formatter |
+90/-0 |
| #104 | feat/expense-job |
feat/expense-endpoint |
+140/-6 |
Працює це завдяки одній деталі: GitHub рахує diff відносно merge-base з base branch. Якщо base для #103 - це feat/expense-formatter, то у вкладці Files changed буде рівно 90 доданих рядків нового endpoint. Код форматера туди не потрапить, хоч фізично він є в гілці.
Тобто рев'ювер бачить одну зміну за раз, навіть якщо ви вже пішли писати наступні три.
Навіщо це, якщо є feature branch
Три конкретні речі, які стек вирішує, а звичайна велика гілка - ні.
Review не блокує роботу. Ви відкрили #101, і замість того, щоб чекати, стартуєте #102 поверх нього. Час очікування review перестає бути простоєм. Це головна причина, чому стеки популярні в командах, де review займає день і більше.
Розмір PR падає до розміру, який реально читають. Дослідження SmartBear на 2500 review в Cisco дало цифру, яку відтоді цитують усі: після приблизно 400 рядків за раз здатність знаходити дефекти падає. Це не магічне число, але напрямок правильний: 90 рядків читають, 2100 - проглядають.
Історія стає зрозумілою. feat/expense-pagination-fix мерджиться окремо. Через півроку, коли ви робите git bisect по регресії в пагінації, ви отримуєте комміт на 30 рядків, а не мега-комміт, у якому пагінація змішана з CSV і cron.
Є ще один ефект, менш очевидний. Стек змушує думати про порядок. Питання "що з цього можна змерджити першим, не ламаючи main?" - це фактично питання про залежності у вашому дизайні. Якщо відповідь "нічого, воно все зчеплене", це сигнал про сам код, а не про Git.
Нативний шлях: gh stack
Розширення до GitHub CLI ставиться однією командою:
1
gh extension install github/gh-stack
Далі той самий приклад з експортом, але вже стеком:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
git switch main
git pull --ff-only
# створюємо стек; trunk за замовчуванням - default branch репозиторію
gh stack init
# перша гілка
# ... правки ...
gh stack add feat/expense-pagination-fix -A -m "fix: skip duplicates in expense pagination"
# друга поверх першої
# ... правки ...
gh stack add feat/expense-formatter -A -m "feat: add CsvExpenseFormatter"
# третя
# ... правки ...
gh stack add feat/expense-endpoint -A -m "feat: GET /expenses/export"
# пушимо все і створюємо зв'язані PR
gh stack submit --open
gh stack add створює гілку поверх поточної верхівки стеку, -A стейджить усі зміни, -m одразу робить комміт. gh stack submit пушить гілки, створює або оновлює PR і зв'язує їх у стек на боці GitHub. Без --open нові PR створюються як draft, тому прапорець варто ставити одразу, якщо ви не збиралися ховати роботу.
Подивитися стан:
1
gh stack view
Навігація по стеку без ручного git switch: gh stack up, gh stack down, gh stack top, gh stack bottom, gh stack switch, gh stack checkout <pr-number>.
Переструктурувати стек інтерактивно (видалити гілку, схлопнути дві сусідні, вставити нову посередині, переставити порядок) - gh stack modify.
Синхронізація з remote після чужих змін у main:
1
gh stack sync --prune
Одна команда робить fetch, каскадний rebase усього стеку, push і оновлення стану PR. --prune видаляє локальні гілки тих PR, які вже змерджені.
CLI не обов'язковий. Стек можна зібрати з веб-інтерфейсу: створюєте перший PR на main, далі при створенні наступного ставите base на гілку першого і відмічаєте чекбокс. GitHub також сам помічає ланцюжки, які виглядають як стек, і показує банер із пропозицією їх зв'язати.
У шапці PR з'являється stack map: усі PR стеку по порядку, поточний підсвічений, решта клікабельні. Саме тому ручний список стеку в описі PR, який радили раніше, більше не потрібен.
Якщо PR уже створені
Стек - це звичайні гілки і звичайні PR, просто з правильним ланцюжком base. Тому ланцюжок, зібраний руками через gh pr create --base, можна зареєструвати як стек заднім числом:
1
gh stack link 101 102 103 104
Порядок аргументів - знизу вгору, від найближчого до main. Локального трекінгу ця команда не заводить, тільки зв'язує PR на боці GitHub.
Правки в нижньому PR
Рев'ювер у #101 просить перейменувати метод. Ви правите гілку внизу, і всі гілки вище тепер відгалужені від старого комміта.
З розширенням це gh stack rebase (каскадний rebase знизу вгору, з --continue і --abort для конфліктів) або кнопка Rebase Stack у merge box, коли GitHub бачить, що історія перестала бути лінійною. Кнопка запускає серверний каскадний rebase і force-push усіх гілок.
Без розширення це ланцюжок ручних rebase, force-push і пояснень рев'юверам, що саме змінилося між версіями. Рутина, яку gh stack і знімає.
Merge
Стеки мерджаться знизу вгору. Але поведінка відрізняється від того, до чого ви звикли на звичайних PR.
Коли ви натискаєте merge на якомусь PR у стеку, разом із ним приземляються всі незмерджені PR нижче. Тобто merge на #103 змерджить #101, #102 і #103 однією операцією. PR вище лишаються відкритими, і стек автоматично перебазовується так, щоб найнижчий незмерджений PR цілився в base branch.
Прямий merge атомарний: або вся група проходить, або не проходить нічого. У merge queue логіка інша - PR заходять у чергу разом і оцінюються поокремо; якщо один падає, він і все, що над ним, виштовхується, а нижні продовжують рух.
Підтримуються всі три методи: merge commit, squash і rebase merge. Squash тут працює так, як хочеться: кожен PR стеку дає один охайний squash-комміт, а незмерджені гілки GitHub перебазовує через git rebase --onto, щоб не отримати штучних конфліктів через новий SHA.
Ось той момент, який раніше був найбільшою пасткою стеків і на якому люди досі губляться.
Якщо ваш ланцюжок не зареєстрований як стек, автоматики немає. GitHub переставить base у #102 на main після мерджу #101 і видалення гілки, але історія feat/expense-formatter усе ще міститиме оригінальні комміти пагінації, яких у main після squash не існує. Diff #102 покаже і форматер, і пагінацію вдруге, іноді ще й з конфліктами. Далі ви розбираєте це вручну через git rebase --onto, попередньо десь знайшовши SHA старої гілки до мерджу.
Тому якщо працюєте стеками, реєструйте їх як стеки. Після gh stack link squash розрулює GitHub, а без нього ви робите це руками щоразу.
Rules і CI
Тут головна зміна порівняно з саморобними стеками.
Кожен PR у стеку оцінюється так, ніби він цілиться в base стеку, а не в гілку прямо під ним. Це стосується required reviews, status checks, CODEOWNERS і code scanning. Тобто branch protection на main більше не обходиться тим, що ваш PR формально цілиться в чужу feature-гілку.
Workflows теж тригеряться так, ніби кожен PR стеку цілиться в base. Раніше зелений CI на середньому PR означав "працює разом з гілкою під ним", і це була постійна плутанина. Тепер він означає те, що очікує людина.
Щоб змерджити PR, усі PR під ним теж мають мати зелені перевірки і задовольняти merge requirements.
У workflow доступні метадані стеку через github.event.pull_request.stack:
1
2
3
- name: Run migrations check only on the lowest unmerged PR
if: github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref
run: ./scripts/check-migrations.sh
Об'єкт містить number (ідентифікатор стеку), size (скільки всього PR), position (позиція, рахується з 1), base.ref і base.sha. На цьому будується економія CI: важкі кроки можна ганяти тільки на найнижчому незмердженому PR або тільки на верхньому (position == size).
Одна деталь, на якій легко зламати автоматизацію: у події pull_request.opened даних про стек ще немає, бо PR створюється до того, як приєднується до стеку. Для цього є окремий action-тип stacked у події pull_request.
І базова гігієна, яка не залежить від стеків. Кожен rebase породжує пуш у кожну гілку, тому без скасування застарілих запусків стек з'їсть бюджет Actions:
1
2
3
concurrency:
group: ci-$-$
cancel-in-progress: true
Обмеження на вересень 2026
Фіча в public preview, і частина речей ще не доїхала:
- Auto-merge для стеків не працює. Обіцяють.
- Bypass merge rules не працює. Теж обіцяють.
- Merge queue підтримується, але розкочувався поступово після 30 липня. Варто перевірити на своєму репозиторії, а не вірити на слово.
- Максимум 100 PR у стеку. Якщо вам мало, проблема не в GitHub.
- Стеки між форками не працюють. Усі гілки мають бути в одному репозиторії, тому для типового OSS-контриб'ютора з форка це поки не варіант.
- GitHub Desktop не підтримує стеки.
- Потрібна лінійна історія. Якщо вона зламалася, чекайте кнопку Rebase Stack або робіть
gh stack rebase. - Merge через API - тільки новий асинхронний Merge API. Старі REST і GraphQL мутації стеків не знають, тому власні скрипти мерджу доведеться переписати.
Відома проблема: "Merge stack" не працює при суворому branch protection
У репозиторії gh-stack є обговорення проблеми, на яку легко натрапити в добре захищеному репозиторії. Автор початкового звіту використовував gh-stack v0.1.0 у приватному репозиторії: стек із п'яти PR, merge commits. На default branch були увімкнені обов'язковий review від CODEOWNER, approval від когось, хто не робив останній push, і лінійна історія. Згодом ще троє людей написали, що бачать те саме у приватних проєктах, один із них зі squash merge. Станом на середину вересня GitHub у треді не відповів.
На що натрапив автор:
- Статус окремих PR і merge стеку суперечать одне одному. Кожен PR показував
mergeStateStatus=CLEANіreviewDecision=APPROVED, зі свіжими approval від CODEOWNER на поточних head SHA, зробленими вже після останньої зміни base. Протеgh stack mergeі кнопка "Merge stack" падали з повідомленням "Waiting on reapproval from someone other than the last pusher. Review is stale because it was submitted before the merge base changed. Waiting on code owner review." - UI показує Ready, а потім здається. Stack map позначав усі PR як Ready, кнопка була активна. Після натискання в timeline PR лише з'являвся запис "stack merge was automatically disabled".
- Стан "у черзі", якого не бачить API.
gh stack unstackвідмовлявся працювати, бо деякі PR нібито "queued for merge or have auto-merge enabled". Водночас GraphQL повертавautoMergeRequest=nullіmergeQueueEntry=nullдля кожного PR, а merge queue в репозиторії не налаштований. Прихований стан зник післяgh pr merge --disable-autoна кожному PR. - Unstack спотикається об змерджені PR. Коли нижні PR уже були змерджені,
gh stack unstackпадав саме на них, замість того щоб прибрати лише відкриті. Пізніше стек таки розпався на сервері, алеgh stack viewлокально й далі його показував. - Статуси скачуть хвилинами.
mergeable,reviewDecisionіmergeStateStatusперемикалися міжUNKNOWN,BLOCKEDіCLEAN, тож важко було відрізнити справжнє блокування від затримки. - Кожен retarget скидає approval від CODEOWNER. Коли нижній PR мерджиться і наступний перенацілюється на trunk, його approval скидається, тож кожен PR по черзі потребує нового approval.
Змерджити стек цілком автору так і не вдалося. Спрацював інший шлях: gh pr merge --disable-auto на кожному PR, дочекатися, поки стек розпадеться на сервері, а потім мерджити PR по одному знизу вгору через gh pr merge, перенацілюючи кожен на trunk.
Якщо на default branch вимагається review від CODEOWNER і approval від когось, хто не робив останній push, спершу спробуйте "Merge stack" на маленькому тестовому стеку й тримайте цей шлях відступу під рукою.
Це вже робили до GitHub
Стеки не нова ідея. Graphite, git-town, ghstack, spr, Sapling - усе це роками закривало ту саму проблему поверх звичайного GitHub, і нікуди не поділося. GitHub прямо каже, що будь-який інструмент, який створює PR із правильним ланцюжком base, сумісний, а результат зв'язується в стек через gh stack link.
Якщо ви починаєте зараз і сидите на GitHub, брати щось стороннє сенсу мало: нативна підтримка закриває базові сценарії, і їй не треба давати окремий доступ до репозиторію.
Коли стек - погана ідея
Стек має ціну. Він додає роботи автору й трохи роботи рев'юверу.
Не варто робити стек, якщо:
- Зміна справді атомарна. Рефакторинг перейменування на 40 файлів не стає зрозумілішим, якщо порізати його на п'ять PR.
- У команді немає культури швидкого review. Стек із п'яти PR, кожен з яких чекає три дні, це не паралелізм, це п'ятнадцять днів і купа rebase.
- Ви контриб'ютите з форка. Просто не спрацює.
- Гілки живуть довго й розходяться з
main. Чим довший стек, тим дорожчий кожен sync.
Розумна межа для більшості команд - три-чотири PR у стеку, хоч технічно дозволено сто. Далі вартість підтримки росте швидше за користь.
Окремо: якщо у вас trunk-based development з коммітами прямо в main за feature flags, стеки вам, найімовірніше, не потрібні взагалі. Ви вже вирішуєте ту саму проблему іншим способом.
Checklist перед першим стеком
gh extension install github/gh-stack, або хоча б перевірено, що стеки доступні у вашому репозиторії.- Ланцюжок зареєстрований як стек, а не просто PR з ручними base. Інакше вся автоматика rebase і squash повз вас.
- У CI налаштований
concurrencyзcancel-in-progress. - Важкі кроки CI обмежені по
github.event.pull_request.stack.position. - Скрипти автоматичного мерджу переведені на асинхронний Merge API або на
gh stack merge. - Команда знає, що merge середнього PR тягне за собою всі незмерджені PR під ним.
- Стек не довший за чотири PR, поки практика не прижилася.
- Якщо на default branch суворі правила review, "Merge stack" спершу перевірено на тестовому стеку.
Стеки передусім змінюють звичку: ви перестаєте накопичувати роботу до моменту "ну тепер уже можна показувати" і віддаєте її шматками, які людина здатна прочитати за один підхід. Перейменувати метод у PR на 30 рядків просто дешевше, ніж у PR на 2100.