Skip to content

Instantly share code, notes, and snippets.

@rfennell
Created January 23, 2026 09:27
Show Gist options
  • Select an option

  • Save rfennell/6bb594364e7a80a14b7aa7019f9fae8e to your computer and use it in GitHub Desktop.

Select an option

Save rfennell/6bb594364e7a80a14b7aa7019f9fae8e to your computer and use it in GitHub Desktop.
Clone All Repos In Azure DevOps Organisation
<#
.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