FJAN Logo

Stacked Pull Requests in Azure DevOps: What Actually Breaks

Publicatiedatum

Leestijd

9 minuten

Delen

Illustration of three stacked pull request cards whose branch lines merge step by step into one main line
Profile photo of Funs Janssen

Geschreven door Funs Janssen

Software Consultant

Ik ben Funs Janssen. Ik ontwikkel software en schrijf over de beslissingen die daarbij komen kijken: architectuur, ontwikkelmethoden, AI-tools en de zakelijke impact van technische keuzes. Deze blog is een verzameling praktische aantekeningen van echte projecten: wat schaalbaar is, wat misgaat en wat in blogvriendelijke voorbeelden vaak over het hoofd wordt gezien.

A feature touches the data model, the API and the UI. You build it on one branch, open one pull request, and it lands at 1,500 lines. Reviewers open it, scroll, sigh, and approve on day three with a comment about naming. The bug ships anyway, because nobody could hold the whole diff in their head.

Stacked pull requests fix that by splitting one feature into a chain of small, dependent PRs. On October 6, GitHub made stacked pull requests generally available, and if your code lives in Azure Repos you are probably wondering whether you can work the same way.

You can. Azure Repos has no stack button, but it has most of the plumbing. In this post you get the exact workflow I recommend, the four things that break when you try it (branch policies, squash merges, vote resets and deleted branches), and the step most teams skip: slicing the work item before you slice the code.

What stacked pull requests are (and what GitHub just shipped)

One feature, several small PRs that each target the branch below

A stack is a chain of branches. The bottom branch targets main. Every branch above it targets the branch directly below. Each pull request shows only its own diff, so a reviewer reads 200 focused lines about the data model, then 250 about the API, then 300 about the UI, instead of 750 lines of everything at once.

The real win is that the author keeps moving. While layer one waits for review, layer two is already being written on top of it. Michaela Greiler's write-up on why stacked pull requests make code reviews faster is a good primer on the review side.

What GitHub automates

GitHub's stacked pull requests GA announcement lists what the platform now handles for you:

  • Approvals are preserved when a rebase does not change the code.
  • Stacks merge as a single group, bottom up.
  • Branches retarget automatically when the base branch is deleted.
  • The gh stack CLI extension manages branches, including Git worktrees.

That automation list is useful, because it is also the list of things you have to do yourself in Azure Repos.

Does Azure Repos support stacked pull requests?

No stack feature, but you can change a PR's target branch

There is no "stack" concept in Azure Repos. What it does have is the one primitive a stack needs: a pull request can target any branch, and you can change that target later. Microsoft documents it in Create pull requests in Azure Repos: on the PR overview, open More actions, choose Change target branch, and pick the new branch.

Everything else in this post is built on that one menu item plus ordinary Git.

Community tools, and how far they get you

There are early open-source tools, such as ado-stack, that wrap this workflow in a CLI with create, submit and restack commands. They can save typing, but they are young projects, and they do not change how Azure DevOps treats your branches. Policies, vote resets and merge types still behave as described below. Learn the manual flow first. Then decide whether a tool earns its place.

How to stack pull requests in Azure Repos, step by step

Name your branches so they read as a stack

Use one prefix per stack and number the layers:

  • stack/order-export/1-data-model
  • stack/order-export/2-api
  • stack/order-export/3-ui

The prefix matters later, because it lets you target these branches with a branch policy pattern. The numbers make the PR list readable without opening anything.

Create each PR against the branch below it

1git switch -c stack/order-export/1-data-model main
2# commit layer 1
3git push -u origin stack/order-export/1-data-model
4
5git switch -c stack/order-export/2-api
6# commit layer 2
7git push -u origin stack/order-export/2-api

Open PR 1 against main. Open PR 2 against stack/order-export/1-data-model, not against main. If you target main, PR 2 shows layer 1's changes too and the whole point is gone. Put "Part 2 of 3, depends on !1234" in the description so reviewers know where they are.

Merge from the bottom up, then retarget

Only the bottom PR ever merges into main. The order is:

  1. Complete PR 1 into main. Untick "Delete source branch" for now.
  2. Open PR 2 and change its target branch to main.
  3. Update layer 2 (next section), push, and get it reviewed.
  4. Now delete the layer 1 branch.

Bring the layers above up to date with a rebase

After PR 1 merges, layer 2 still sits on the old layer 1 commits. Rebase it onto main:

1git fetch origin
2git switch stack/order-export/2-api
3git rebase --onto origin/main stack/order-export/1-data-model
4git push --force-with-lease

--onto replays only layer 2's own commits onto main and drops layer 1's originals. Repeat for each layer above. Always use --force-with-lease, never a bare --force, so you cannot overwrite a teammate's push.

Four things that break, and how to fix them

This is where most stacking attempts in Azure DevOps fall over. None of these problems are hard, but each one will make your team distrust the workflow if you hit it unprepared.

1. Branch policies do not apply to stack branches

Your policies on main (required reviewers, build validation, linked work items) apply to PRs that target main. PR 2 and PR 3 target a stack branch, so by default they get no build, no required reviewer and no policy checks at all. Layers can get "approved" with a red build.

Fix: add a cross-repo policy in Project settings > Repositories > Policies that protects branches matching a pattern such as stack/*. Microsoft describes this under repository settings and policies. Give it the same build validation and minimum reviewers as main. Now every layer is checked on its own, which is what GitHub means when it says protection rules apply to all layers of a stack.

2. Squash merges bring old changes back

Many teams limit merge types to squash merge on main. When PR 1 squash merges, main gets one new commit with a new hash. Layer 2 still contains the original layer 1 commits, so after you retarget PR 2 to main, its diff suddenly shows layer 1's changes again and reviewers think something went wrong.

Fix: the git rebase --onto step above. It drops the original layer 1 commits before you push. Do it right after retargeting and the diff shrinks back to layer 2 only. If your team cannot live with that step, allow "Rebase and fast-forward" for stack PRs instead, which keeps commit identities intact. The options are listed under Limit merge types in Azure Repos branch policies.

3. Vote resets wipe approvals after every rebase

Under "When new changes are pushed", many teams pick Reset all approval votes or Reset all code reviewer votes. Every rebase is a new push. So every time a lower layer merges and you restack, every layer above loses its approvals, even when the code did not change.

Fix: pick one of these:

  • Use Require at least one approval on the last iteration instead of a full reset. You still get a fresh approval on the final code, but not a full re-review.
  • Restack before asking for review, not after. Review the top layers only once the layers below have merged.
  • Keep stacks shallow. Two or three layers, not seven.

4. Deleting the base branch too early

Completing PR 1 with Delete source branch ticked removes the branch PR 2 is still targeting. PR 2 is then left pointing at a branch that no longer exists, and you have to sort it out after the fact.

Fix: the order from the step-by-step section. Merge, retarget, restack, then delete. Write it in your team's PR guidelines, because the default habit of "tick delete and move on" is exactly wrong for stacks.

Slice the work item before you slice the code

Here is the part that usually gets skipped. A stack is only as good as its seams. If the seams are decided while coding, layer 2 ends up half data model and half API, and the review benefit disappears.

One child work item per layer

Before the first commit, split the user story into child items that match the layers:

  • Task 1: add the ExportJob table and migration.
  • Task 2: expose POST /orders/export with validation.
  • Task 3: add the export button and progress state.

Link each PR to its own child item. With the Check for linked work items policy on stack/*, that link is enforced, and the board shows which layers are done. The story only closes when every layer is merged. I wrote more about moving quality checks upstream in Guard AI Development at the Work Item, Not the PR.

A Definition of Done for each layer

A layer that merges into main is shipping code, even if the feature is not visible yet. So each layer needs its own small Definition of Done: tests for that layer, a feature flag if the UI is half built, migrations that are backward compatible. If you want "Done" to be enforced rather than hoped for, the approach in Make "Done" Mean Done in Azure DevOps Boards applies directly to these child items.

Is this a reason to move from Azure Repos to GitHub?

When the manual workflow is enough

If your team stacks a few times per sprint, with two or three layers, the workflow above is fine. The pattern policy and the --onto rebase are the only real changes, and both are a one-time learning cost. Small PRs are worth it either way; see How to Keep Pull Requests Small for the habits that make stacking easier.

When the move pays off

If stacking becomes the default way your team works, the manual retargeting and the lost approvals add up every single day. That is when GitHub's native stacks are a real argument for moving the repositories. You do not have to move your backlog with them. I walked through keeping Azure Boards, your links and your Definition of Done in Azure Repos to GitHub: Keep Boards, Links and Your DoD.

Conclusion: stacks start in the backlog

Stacked pull requests in Azure DevOps work today with a few deliberate choices:

  • Target each PR at the branch below, and retarget to main after each merge.
  • Protect stack/* with a cross-repo policy so every layer gets a build and a reviewer.
  • Use git rebase --onto after squash merges.
  • Pick a vote reset setting that does not punish restacking.
  • Merge, retarget, restack, then delete.

The step that makes or breaks a stack happens before any code: splitting the story into child items that match the layers. That is the work I built ClearSpecs AI for. It works inside Azure Boards to break a story into well-defined child items, each with its own clear title and acceptance criteria, so your stack has clean seams from day one. You can install ClearSpecs AI from the Visual Studio Marketplace and try it on your next story. If you also want a per-layer Definition of Done that blocks a child item from closing until its checklist is complete, the free Checklist Extension for Azure DevOps does exactly that.

Frequently asked questions

Reacties

Nog geen reacties. Wees de eerste om te reageren.

Plaats een reactie

Profile photo of Funs Janssen

Geschreven door Funs Janssen

Software Consultant

Ik ben Funs Janssen. Ik ontwikkel software en schrijf over de beslissingen die daarbij komen kijken: architectuur, ontwikkelmethoden, AI-tools en de zakelijke impact van technische keuzes. Deze blog is een verzameling praktische aantekeningen van echte projecten: wat schaalbaar is, wat misgaat en wat in blogvriendelijke voorbeelden vaak over het hoofd wordt gezien.

Inhoud