Created
January 23, 2026 09:27
-
-
Save rfennell/6bb594364e7a80a14b7aa7019f9fae8e to your computer and use it in GitHub Desktop.
Clone All Repos In Azure DevOps Organisation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Lists all Git repositories across all projects in an Azure DevOps organization and supports multiple output formats or cloning locally. | |
| .DESCRIPTION | |
| This script enumerates all projects in an Azure DevOps organization using the Azure CLI DevOps extension, then lists Git repositories for each project. | |
| It can output repository metadata in `json`, `csv`, or `table` formats, emit clone `urls` (HTTPS/SSH/both), or `clone` all repositories locally using `git`. | |
| Prerequisites: | |
| - Azure CLI installed and logged in (`az login`). | |
| - Azure DevOps extension installed (`az extension add --name azure-devops`). | |
| - Sufficient permissions to list projects and repos. | |
| - `git` installed and on PATH when using `-Format clone`. | |
| .PARAMETER OrgUrl | |
| Azure DevOps organization URL, e.g. https://dev.azure.com/<ORG> | |
| .PARAMETER Format | |
| Output format. One of: `json`, `csv`, `table`, `urls`, `clone`. | |
| .PARAMETER OutFile | |
| Optional path to write output. For `json`, `urls` formats, defaults to console when omitted. For `csv`, defaults to `repos.csv` in the current directory when omitted. | |
| .PARAMETER CloneProtocol | |
| Clone URL protocol for `urls`/`clone` formats. One of: `https`, `ssh`, `both` (both only applies to `urls`). Default: `https`. | |
| .PARAMETER DestinationRoot | |
| Root directory where repositories are cloned when using `-Format clone`. Project and repo names are sanitized into subfolders. | |
| .PARAMETER ProjectFilter | |
| Optional list of project names to include when cloning. If provided, only repos from these projects are processed in `clone` mode. | |
| .EXAMPLE | |
| pwsh ./azdo/list-all-repos.ps1 -OrgUrl "https://dev.azure.com/<ORG>" -Format json | |
| Outputs JSON metadata for all repos to the console. | |
| .EXAMPLE | |
| pwsh ./azdo/list-all-repos.ps1 -OrgUrl "https://dev.azure.com/<ORG>" -Format urls -CloneProtocol ssh -OutFile repos.txt | |
| Writes SSH clone URLs for all repos to repos.txt. | |
| .EXAMPLE | |
| pwsh ./azdo/list-all-repos.ps1 -OrgUrl "https://dev.azure.com/<ORG>" -Format clone -CloneProtocol https -DestinationRoot D:\\archive -ProjectFilter "ProjectA","ProjectB" | |
| Clones all repos from ProjectA and ProjectB via HTTPS into D:\\archive, updating existing clones when found. | |
| .NOTES | |
| - Existing local repos are updated (fetch/prune + fast-forward pull). Origin URL is aligned to selected protocol if different. | |
| - TFVC repos are not included. | |
| - Large orgs are handled via continuation tokens. | |
| #> | |
| param ( | |
| [Parameter(Mandatory = $true, HelpMessage = "Azure DevOps organization URL, e.g. https://dev.azure.com/<ORG>")] | |
| [string]$OrgUrl, | |
| [Parameter(Mandatory = $false, HelpMessage = "Output format. One of: json, csv, table, urls, clone")] | |
| [ValidateSet('json','csv','table','urls','clone')] | |
| [string]$Format = 'json', | |
| [Parameter(Mandatory = $false, HelpMessage = "Optional output file path for json, csv, or urls formats.")] | |
| [string]$OutFile, | |
| [Parameter(Mandatory = $false, HelpMessage = "Clone URL protocol for urls/clone formats. One of: https, ssh, both")] | |
| [ValidateSet('https','ssh','both')] | |
| [string]$CloneProtocol = 'https', | |
| [Parameter(Mandatory = $false, HelpMessage = "Root directory for cloning repositories when using 'clone' format.")] | |
| [string]$DestinationRoot, | |
| [Parameter(Mandatory = $false, HelpMessage = "Optional list of project names to include when cloning.")] | |
| [string[]]$ProjectFilter | |
| ) | |
| $ErrorActionPreference = 'Stop' | |
| function Invoke-AzCliJson { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$Args | |
| ) | |
| # Invoke Azure CLI and return parsed JSON. Throws on non-zero exit. | |
| $raw = & az @Args | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "Azure CLI failed: $raw" | |
| } | |
| try { | |
| return $raw | ConvertFrom-Json | |
| } catch { | |
| throw "Failed to parse Azure CLI JSON output. Raw: $raw" | |
| } | |
| } | |
| function Invoke-Git { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string[]]$Args | |
| ) | |
| $raw = & git @Args | |
| if ($LASTEXITCODE -ne 0) { | |
| throw "git failed: $raw" | |
| } | |
| return $raw | |
| } | |
| function Sanitize-Name { | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Name | |
| ) | |
| $invalid = [System.IO.Path]::GetInvalidFileNameChars() | |
| foreach ($c in $invalid) { $Name = $Name.Replace($c, '_') } | |
| return $Name.Trim() | |
| } | |
| Write-Host "Listing projects in org: $OrgUrl" -ForegroundColor Cyan | |
| # Collect all projects (handle continuation token for large orgs) | |
| $projects = @() | |
| $continuationToken = $null | |
| do { | |
| $args = @( | |
| 'devops','project','list', | |
| '--org', $OrgUrl, | |
| '--state-filter','wellFormed', | |
| '--top','100', | |
| '-o','json', | |
| '--query','{continuationToken: continuationToken, value: value[].{id:id,name:name}}' | |
| ) | |
| if ($continuationToken) { $args += @('--continuation-token', $continuationToken) } | |
| $resp = Invoke-AzCliJson -Args $args | |
| # Response may be an object with value[] and continuationToken | |
| if ($resp.PSObject.Properties['value']) { | |
| foreach ($p in $resp.value) { $projects += $p } | |
| $continuationToken = $resp.continuationToken | |
| } else { | |
| # Some CLI versions may directly return an array | |
| foreach ($p in @($resp)) { $projects += $p } | |
| $continuationToken = $null | |
| } | |
| Write-Host "Fetched $($projects.Count) projects so far..." -ForegroundColor DarkGray | |
| } while ($continuationToken) | |
| if (-not $projects -or $projects.Count -eq 0) { | |
| Write-Warning "No projects found or insufficient permissions." | |
| } | |
| # Aggregate repos across all projects | |
| $allRepos = @() | |
| foreach ($proj in $projects) { | |
| $projectId = $proj.id | |
| $projectName = $proj.name | |
| $projectRef = if ($projectId) { $projectId } elseif ($projectName) { $projectName } else { $null } | |
| if (-not $projectRef) { | |
| Write-Warning ("Skipping project with missing id/name: " + ($proj | ConvertTo-Json -Depth 3)) | |
| continue | |
| } | |
| Write-Host "Listing repos for project: $projectName ($projectRef)" -ForegroundColor Cyan | |
| $repoArgs = @('repos','list','--org', $OrgUrl, '--project', $projectRef, '-o','json') | |
| $repos = Invoke-AzCliJson -Args $repoArgs | |
| foreach ($r in $repos) { | |
| $allRepos += [PSCustomObject]@{ | |
| RepoId = $r.id | |
| RepoName = $r.name | |
| ProjectId = $r.project.id | |
| ProjectName = $r.project.name | |
| WebUrl = $r.webUrl | |
| RemoteUrl = $r.remoteUrl | |
| SshUrl = $r.sshUrl | |
| DefaultBranch = $r.defaultBranch | |
| } | |
| } | |
| } | |
| Write-Host "Total repos found: $($allRepos.Count)" -ForegroundColor Green | |
| # Output formatting | |
| switch ($Format) { | |
| 'json' { | |
| $json = $allRepos | ConvertTo-Json -Depth 6 | |
| if ($OutFile) { | |
| $json | Set-Content -Encoding UTF8 -Path $OutFile | |
| Write-Host "JSON written to $OutFile" -ForegroundColor Green | |
| } else { | |
| $json | |
| } | |
| } | |
| 'csv' { | |
| if (-not $OutFile) { $OutFile = "repos.csv" } | |
| $allRepos | Export-Csv -NoTypeInformation -Path $OutFile | |
| Write-Host "CSV written to $OutFile" -ForegroundColor Green | |
| } | |
| 'table' { | |
| $allRepos | Format-Table RepoName, ProjectName, WebUrl -AutoSize | |
| } | |
| 'urls' { | |
| $urls = switch ($CloneProtocol) { | |
| 'https' { $allRepos | ForEach-Object { $_.RemoteUrl } } | |
| 'ssh' { $allRepos | ForEach-Object { $_.SshUrl } } | |
| 'both' { $allRepos | ForEach-Object { $_.RemoteUrl; $_.SshUrl } } | |
| } | |
| # Filter out null/empty entries | |
| $urls = $urls | Where-Object { $_ -and $_.Trim().Length -gt 0 } | |
| if ($OutFile) { | |
| $urls | Set-Content -Encoding UTF8 -Path $OutFile | |
| Write-Host "Clone URLs written to $OutFile" -ForegroundColor Green | |
| } else { | |
| $urls | ForEach-Object { $_ } | |
| } | |
| } | |
| 'clone' { | |
| # Verify git availability | |
| try { Invoke-Git -Args @('--version') | Out-Null } catch { throw "'git' is not available on PATH. Please install Git." } | |
| if (-not (Test-Path -Path $DestinationRoot)) { | |
| Write-Host "Creating destination root: $DestinationRoot" -ForegroundColor DarkGray | |
| New-Item -ItemType Directory -Path $DestinationRoot -Force | Out-Null | |
| } | |
| $reposToClone = $allRepos | |
| if ($ProjectFilter -and $ProjectFilter.Count -gt 0) { | |
| $reposToClone = $reposToClone | Where-Object { $ProjectFilter -contains $_.ProjectName } | |
| } | |
| $count = 0 | |
| foreach ($repo in $reposToClone) { | |
| $count++ | |
| $projDir = Join-Path $DestinationRoot (Sanitize-Name -Name $repo.ProjectName) | |
| $repoDir = Join-Path $projDir (Sanitize-Name -Name $repo.RepoName) | |
| $url = switch ($CloneProtocol) { | |
| 'https' { $repo.RemoteUrl } | |
| 'ssh' { $repo.SshUrl } | |
| default { $repo.RemoteUrl } | |
| } | |
| if (-not $url) { | |
| Write-Warning "Skipping repo without clone URL: $($repo.ProjectName)/$($repo.RepoName)" | |
| continue | |
| } | |
| # If repo already exists, update it instead of skipping | |
| if ((Test-Path -Path $repoDir) -and (Test-Path -Path (Join-Path $repoDir '.git'))) { | |
| Write-Host "[$count] Updating existing: $repoDir" -ForegroundColor DarkGray | |
| try { | |
| # Align origin URL with selected protocol if different | |
| $origin = (Invoke-Git -Args @('-C', $repoDir, 'remote', 'get-url', 'origin')).Trim() | |
| if ($origin -and $url -and ($origin -ne $url)) { | |
| Write-Host "[$count] Updating origin URL -> $url" -ForegroundColor DarkGray | |
| Invoke-Git -Args @('-C', $repoDir, 'remote', 'set-url', 'origin', $url) | Out-Null | |
| } | |
| Invoke-Git -Args @('-C', $repoDir, 'fetch', '--all', '--prune') | Out-Null | |
| Invoke-Git -Args @('-C', $repoDir, 'pull', '--ff-only') | Out-Null | |
| Write-Host "[$count] Updated: $repoDir" -ForegroundColor Green | |
| } catch { | |
| Write-Warning "[$count] Update failed: $repoDir. Error: $($_.Exception.Message)" | |
| } | |
| continue | |
| } | |
| if (-not (Test-Path -Path $projDir)) { New-Item -ItemType Directory -Path $projDir -Force | Out-Null } | |
| Write-Host "[$count] Cloning $url -> $repoDir" -ForegroundColor Cyan | |
| try { | |
| Invoke-Git -Args @('clone', $url, $repoDir) | Out-Null | |
| Write-Host "[$count] Cloned: $repoDir" -ForegroundColor Green | |
| } catch { | |
| Write-Warning "[$count] Clone failed: $url -> $repoDir. Error: $($_.Exception.Message)" | |
| } | |
| } | |
| Write-Host "Completed clone/update for $count repositories." -ForegroundColor Green | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment