Delete stale branches in Azure DevOps

Introduction

In my experience, there are two main reasons stale branches exist in Azure DevOps (or any source code repo):

  1. Branches are not deleted after completing pull request. Although Azure DevOps gives you option to delete branch after merging given the right permissions are applied. But this option is not used most of the time
  2. Developers create temp branches for proof of concepts, resolve merging conflicts or any other reason and then forget to delete them
In this exercise, we will delete all the branches left as result of two cases.

Prerequisite

a. Install the Azure Cli
b. Install the Azure Cli DevOps extension through PowerShell by running following command


$ az extension add --name azure-devops

Write deletion scripts

Start PowerShell and login to Azure


$ az login

a. Script to delete branches that have completed the pull request (delete-pull-request-completed-branches.ps1)


$project = "{projectName}"
$repository = "{repoName}"
if (-not (Test-Path env:IS_DRY_RUN)) { $env:IS_DRY_RUN = $true }
Write-Host ("is dry run: {0}" -f $env:IS_DRY_RUN)

$prsCompleted = az repos pr list `
    --project $project `
    --repository $repository `
    --target-branch develop `
    --status completed `
    --query "[].sourceRefName" |
ConvertFrom-Json;

if ($prsCompleted.count -eq 0) {
    Write-Host "No merged pull request"
    return;
}

$refs = az repos ref list --project $project --repository $repository --filter heads | ConvertFrom-Json
$refs |
Where-Object { $prsCompleted.Contains( $_.name ) } |
ForEach-Object {
    Write-Host ("deleting merged branch: {0} - {1}" -f $_.name, $_.objectId)
    if (![System.Convert]::ToBoolean($env:IS_DRY_RUN)) {
        $result = az repos ref delete `
            --name $_.name `
            --object-id $_.objectId `
            --project $project `
            --repository $repository |
        ConvertFrom-Json
        Write-Host ("success message: {0}" –f $result.updateStatus)
    }
}

Notes:

  • Replace {projectName} and {repoName} with your own project and repository name
  • Use IS_DRY_RUN environment variable to list the branches without deleting them. It can be changed manually when running on local machine or can be setup as Azure DevOps pipeline build variable

b. Script to delete old, staled branches (delete-stale-branches.ps1)


$project = "projectName"
$repository = "repoName"
$excludeBranches = @("develop", "master")
$daysDeleteBefore = -30
$dateTimeNow = [DateTime]::Now
$dateTimeBeforeToDelete = $dateTimeNow.AddDays( $daysDeleteBefore)
if (-not (Test-Path env:IS_DRY_RUN)) { $env:IS_DRY_RUN = $true }
Write-Host ("is dry run: {0}" -f $env:IS_DRY_RUN)
Write-Host ("datetime now: {0}" -f $dateTimeNow)
Write-Host ("delete branches before {0}" -f (get-date $dateTimeBeforeToDelete))

$refs = az repos ref list --project $project --repository $repository --filter heads | ConvertFrom-Json

$toDeleteBranches = @()
 
foreach ($ref in $refs) {

    if ($ref.name -replace "refs/heads/" -in $excludeBranches) {
        continue;
    }

    $objectId = $ref.objectId
 
    # fetch individual commit details
    $commit = az devops invoke `
        --area git `
        --resource commits `
        --route-parameters `
        project=$project `
        repositoryId=$repository `
        commitId=$objectId |
    ConvertFrom-Json
 
    $toDelete = [PSCustomObject]@{ 
        objectId     = $objectId
        name         = $ref.name
        creator      = $ref.creator.uniqueName
        lastAuthor   = $commit.committer.email
        lastModified = $commit.push.date
    }
    $toDeleteBranches += , $toDelete
}

$toDeleteBranches = $toDeleteBranches | Where-Object { (get-date $_.lastModified) -lt (get-date $dateTimeBeforeToDelete) }

if ($toDeleteBranches.count -eq 0) {
    Write-Host "No stale branches to delete"
    return;
}

$toDeleteBranches |
ForEach-Object {
    Write-Host ("deleting staled branch: name={0} - id={1} - lastModified={2}" -f $_.name, $_.objectId, $_.lastModified)
    if (![System.Convert]::ToBoolean($env:IS_DRY_RUN)) {
        $result = az repos ref delete `
            --name $_.name `
            --object-id $_.objectId `
            --project $project `
            --repository $repository |
        ConvertFrom-Json
        Write-Host ("success message: {0}" -f $result.updateStatus)
    }
}

Notes:

  • Use $excludeBranches for excluding branches you do not want to delete
  • Use $daysDeleteBefore for indicating the number of days before to delete branches

Define the Azure DevOps pipeline (azure-pipelines.yml)


name: Delete Branches
 
schedules:
  # run at midnight every day
  - cron: "0 0 * * *"
    displayName: Delete branches
    branches:
      include:
        - develop
pool:
  vmImage: ubuntu-latest

steps:
- script: |
    az extension add -n azure-devops
  displayName: Install Azure DevOps CLI

- checkout: self
  clean: true

- script: |
    echo $(ACCESS_TOKEN) | az devops login
  displayName: Login
  env:
    ADO_PAT_TOKEN: $(ACCESS_TOKEN)

- pwsh: .\delete-pull-request-completed-branches.ps1
  displayName: 'Delete merged branches'
  
  
- pwsh: .\delete-stale-branches.ps1
  displayName: 'Delete stale branches'

Notes:

  • Schedule is defined to run the job at midnight
  • ubuntu-latest is used as build machine
  • First step install the Azure DevOps extension
  • Second step login to Azure DevOps using PAT token. ACCESS_TOKEN should be defined as as Azure DevOps pipeline build variable
  • Third step run the script to delete merged pull requests
  • Fourth step run the script to delete stale branches

Resources: