IBMDO YOU?Hi, I'm MBO!

Settings

Make the site feel at home on your screen.

Theme

Loading your theme preference.

Keyboard shortcuts

Open search from anywhere, then move through the results without leaving the keyboard.

Open settings
Ctrl,or⌘,
Open search
CtrlKor⌘K
Select a search result
↑↓
Open the selected result
Enter
Close an open dialog
Esc

Tech

Azure DevOps merge types: why squash caught out my release branch

The four Azure Repos pull request merge options, what they do to commit history, and why a previous squash merge can bring old conflicts back on a release branch.

Haven't we merged this already?

I recently hit conflicts while merging into a release branch. Some of the files involved had come across in the previous merge. Why were we arguing about them again?

The previous pull request had been completed with a squash commit.

That brought the changes across, but it didn't connect the original commits to the release branch's ancestry. When the branches met again, Git couldn't use that previous integration as their shared starting point.

For repeated merges between branches that remain in use, a basic merge commit is usually the better fit. Squash is useful for finishing a short-lived feature branch, where you want one commit and will start subsequent work from the updated target.

The example histories use develop and release to show how each strategy affects ancestry.

The four choices in Azure DevOps

Azure Repos offers four PR completion strategies. Labels can differ between the completion dialog and branch policies; Microsoft documents them in PR completion and Limit merge types.

Merge option What happens Where it fits
Merge (no fast-forward) / basic merge Keeps source commits and adds a commit with both branch tips as parents. Repeated integration between long-lived branches; preserving the branch relationship.
Squash commit / squash merge Combines the PR's changes into one new target commit. Original source commits aren't its parents. A completed feature whose intermediate commits don't need to remain in target history.
Rebase and fast-forward Replays source commits onto the target, then advances the target without a merge commit. Replayed commits generally have new IDs. A linear history with individual commits retained as replayed versions.
Semi-linear merge / rebase with merge commit Rebases the source commits onto the target, then adds a two-parent merge commit. A rebased history with an explicit PR boundary.

The rebase options change ancestry too. A merge commit created after rebasing connects the rebased commits; it doesn't necessarily preserve the relationship to the original commits on a continuing source branch. For recurring release integrations, preserving the existing source commits is the useful property of a basic merge.

What squash actually leaves behind

Suppose develop contains commits B and C, both based on A. We squash those changes into release as S, then continue working on develop:

      B---C---D   develop
     /
    A---S        release

S contains the combined changes from B and C. There is no parent link from S to C. Microsoft illustrates this behaviour in its squash merge guide.

With a regular merge instead, the picture would be:

      B---C---D   develop
     /    \
    A------M     release

M has parents A and C. When merging D later, Git can use C as the shared ancestor in this simple history.

Why the old files can conflict again

Git normally performs a three-way merge using a common ancestor, the target tip and the source tip. A suitable common ancestor is called a merge base; see the Git merge-base reference.

In the squash diagram, that base is still A. Git compares the changes from A to S with those from A to D, including work already represented by the squash.

For example, imagine a line was mode=old at A. Commit C changed it to mode=new, which also appears in S. Commit D then changes it to mode=newer. Relative to A, both branches changed the same line to different values. That can conflict even though the development sequence looks obvious to us.

Squash doesn't guarantee a conflict: identical changes can merge cleanly. The problem is the missing ancestry relationship, which can make earlier changes and resolutions relevant again. Git's FAQ on merging and rebasing explicitly warns about repeatedly merging branches after a squash merge.

Check the history before fixing the files

Replace these example branch names with your own. Fetch first, then inspect the remote branch tips:

git fetch origin
git log --graph --oneline --decorate origin/release origin/develop
git merge-base origin/release origin/develop
git log --oneline origin/release..origin/develop
git diff origin/release origin/develop

The log range shows commits reachable from develop but not release. The diff compares their current file contents. After a squash, a commit can appear in that log even though its changes are already present in the release files.

Also check the previous PR's completion strategy. A commit message alone isn't proof that a commit was a squash.

Recover through a temporary integration branch

Changing the next PR's merge type doesn't retroactively restore the missing relationship. The next integration may still need conflict resolution.

If all current source changes belong in this release, one approach is to create a temporary branch from the release tip and merge the source into it. Start with a clean working tree:

git status
git fetch origin
git switch -c reconcile-release origin/release
git merge --no-ff origin/develop

For conflicts, resolve the intended combined result, stage the resolved files individually, and complete the merge:

git add path/to/resolved-file
git merge --continue

If the merge completes without conflicts, Git creates the merge commit directly. If you need to abandon an in-progress merge, use git merge --abort. These behaviours are covered in the Git merge reference.

Review the full release diff and run the project's checks before pushing the temporary branch and opening a PR targeting release. Complete that PR with Merge (no fast-forward) so the source ancestry remains reachable from the release branch. Squashing this reconciliation PR would lose the relationship again.

Don't accept one whole side just to clear the conflict counter: release-only fixes may need to survive. If only selected changes belong in the release, use a separately reviewed backport instead of merging the entire development tip. That is a different workflow and won't establish that the whole source branch has been integrated.

Set the policy to match the workflow

For this pattern, my preference would be:

  • Squash completed feature PRs into development, then start new features from the updated development branch.
  • Use basic merge commits for recurring development-to-release integrations.
  • Review release-only hotfixes for integration back into development.

In Azure DevOps, open Repos → Branches, find the target branch's Branch policies, and check Limit merge types. A squash-only policy can prevent the completion strategy this workflow needs. Agree the release policy with the team and retain the required reviews and build checks.

The lesson from this one: the merge dropdown changes what future merges can understand. A tidy commit list is useful, but so is Git remembering that these branches have met before.

Find the fix

Search articles

Esc

Search titles, technical terms or error codes.