Azure DevOps Repo Cloner (PowerShell & AzureCli)
Summary
A secure, user-friendly PowerShell script to clone all accessible Git repositories from an Azure DevOps organization — no admin rights required.
Features
- Interactive prompts for:
- Organization URL
- Local clone folder
- Log file path (auto-creates .log, validates write access)
 
- Logs to both console and file with timestamps and color-coding
- Only clones repos the user has access to — respects Azure DevOps permissions
- Skips already cloned repos — safe to re-run
- No local admin rights needed — works for developers, contributors, externals
- Supports PAT or browser login ($env:AZURE_DEVOPS_EXT_PAT)
- Robust error handling — never crashes on permission issues
Requirements
- Azure CLI (az)
- azure-devopsextension (- az extension add --name azure-devops)
- Git (git)
- PowerShell 5.1+ (7+ recommended)
How to Use
- Save the script as Clone-All-DevOpsRepos.ps1
- Run in PowerShell:
.\Clone-All-DevOpsRepos.ps1
<#
.SYNOPSIS
    Clone all Azure DevOps Git repositories from every project.
.DESCRIPTION
    Interactive or non-interactive (PAT). Organizes repos as:
    <LocalFolder>/<Project>/<Repo>
    Logs to both console and a user-specified log file (with .log auto-added).
.NOTES
    • Requires: Azure CLI + `azure-devops` extension
    • Use $env:AZURE_DEVOPS_EXT_PAT for CI/CD
    • PowerShell 7+ recommended
.EXAMPLE
    .\Clone-All-DevOpsRepos.ps1
    # Prompts for:
    #   Organization URL
    #   Local folder path
    #   Log file path (e.g. C:\Logs\clone.log)
#>
[CmdletBinding()]
param()
# Global log stream and path
$LogStream = $null
$LogFilePath = $null
# -------------------------------------------------
#  Helper: Colored, timestamped logging (console + file)
# -------------------------------------------------
function Write-Log {
    param(
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS')][string]$Level = 'INFO'
    )
    $timestamp = Get-Date -Format "HH:mm:ss"
    $logLine = "[$timestamp] [$Level] $Message"
    # Console output with color
    $color = switch ($Level) {
        'INFO' { 'White' }
        'WARN' { 'Yellow' }
        'ERROR' { 'Red' }
        'SUCCESS' { 'Green' }
    }
    Write-Host $logLine -ForegroundColor $color
    # Write to file
    if ($LogStream) {
        try { $LogStream.WriteLine($logLine) } catch { }
    }
}
# -------------------------------------------------
#  1. Get Organization URL
# -------------------------------------------------
do {
    $org = Read-Host "Enter Azure DevOps organization URL (e.g. https://dev.azure.com/contoso)"
    $org = $org.Trim()
    if (-not $org) { Write-Host "Organization URL cannot be empty." -ForegroundColor Red }
} while (-not $org)
Write-Log "Using organization: $org" SUCCESS
# -------------------------------------------------
#  2. Get Local Clone Folder
# -------------------------------------------------
do {
    $folder = Read-Host "Enter local folder to clone repos into (e.g. C:\Repos or ./backup)"
    $folder = $folder.Trim()
    if (-not $folder) { Write-Host "Folder path cannot be empty." -ForegroundColor Red; continue }
    try {
        $resolved = Resolve-Path -Path $folder -ErrorAction Stop
        $LocalFolder = $resolved.Path
        break
    }
    catch {
        try {
            New-Item -ItemType Directory -Path $folder -Force | Out-Null
            $LocalFolder = (Resolve-Path -Path $folder).Path
            break
        }
        catch {
            Write-Host "Invalid or inaccessible path: $folder" -ForegroundColor Red
        }
    }
} while ($true)
Write-Log "Cloning into: $LocalFolder" SUCCESS
# -------------------------------------------------
#  3. Get Log FILE Path (with .log auto-add)
# -------------------------------------------------
do {
    Write-Host "`nEnter FULL PATH for LOG FILE (e.g. C:\Logs\clone.log or ./clone.log)" -ForegroundColor Cyan
    $logInput = Read-Host "Log file path"
    $logInput = $logInput.Trim()
    if (-not $logInput) {
        Write-Host "Log file path is required." -ForegroundColor Red
        continue
    }
    # Auto-add .log if missing
    if (-not $logInput.EndsWith('.log', [System.StringComparison]::OrdinalIgnoreCase)) {
        $logInput = "$logInput.log"
        Write-Host "Auto-added .log → $logInput" -ForegroundColor DarkGray
    }
    $logDir = Split-Path $logInput -Parent
    if (-not $logDir) { $logDir = "." }
    try {
        # Ensure directory exists
        if (-not (Test-Path $logDir)) {
            New-Item -ItemType Directory -Path $logDir -Force | Out-Null
            Write-Log "Created log directory: $logDir" INFO
        }
        # Test write access
        $testFile = Join-Path $logDir "log_$(Get-Random).tmp"
        "log" | Out-File $testFile -Force -Encoding utf8
        Remove-Item $testFile -Force
        # Resolve full path
        $LogFilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($logInput)
        # Open stream for appending
        $LogStream = [System.IO.StreamWriter]::new($LogFilePath, $true, [System.Text.Encoding]::UTF8)
        $LogStream.AutoFlush = $true
        Write-Log "Logging enabled to: $LogFilePath" SUCCESS
        break
    }
    catch {
        Write-Host "Cannot write to: $logInput" -ForegroundColor Red
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        if ($_.Exception.Message -like "*Access*denied*") {
            Write-Host "Tip: Run as Administrator, or use a user-writable folder (e.g. Documents)." -ForegroundColor Yellow
        }
    }
} while ($true)
# -------------------------------------------------
#  4. Ensure Azure CLI + extension
# -------------------------------------------------
Write-Log "Installing azure-devops extension..." INFO
az extension add --name azure-devops --yes | Out-Null
if ($LASTEXITCODE) { Write-Log "Failed to install extension" ERROR; exit 1 }
# -------------------------------------------------
#  5. Authenticate
# -------------------------------------------------
Write-Log "Authenticating to Azure DevOps..." INFO
if (-not $env:AZURE_DEVOPS_EXT_PAT) {
    Write-Host "Opening browser for login (close when done)..." -ForegroundColor Cyan
    az login --allow-no-subscriptions | Out-Null
    if ($LASTEXITCODE) { Write-Log "Login failed" ERROR; exit 1 }
}
az devops configure --defaults organization=$org | Out-Null
if ($LASTEXITCODE) { Write-Log "Failed to set default org" ERROR; exit 1 }
# -------------------------------------------------
#  6. Get all projects
# -------------------------------------------------
Write-Log "Fetching projects..." INFO
$projectsJson = az devops project list --organization $org -o json
if ($LASTEXITCODE) { Write-Log "Failed to list projects" ERROR; exit 1 }
$projects = ($projectsJson | ConvertFrom-Json).value | Select-Object -ExpandProperty name
if (-not $projects) {
    Write-Log "No projects found. Check URL and permissions." ERROR
    if ($LogStream) { $LogStream.Close(); $LogStream.Dispose() }
    exit 1
}
Write-Log "Found $($projects.Count) project(s): $($projects -join ', ')" SUCCESS
# -------------------------------------------------
#  7. Function: Clone a single repo
# -------------------------------------------------
function Clone-Repo {
    param($Project, $RepoName, $RepoUrl, $Destination)
    if (Test-Path $Destination) {
        Write-Log "  [SKIP] $RepoName (already exists)" WARN
        return
    }
    Write-Log "  [CLONE] $RepoName ..." INFO
    $output = git clone $RepoUrl $Destination 2>&1
    if ($LASTEXITCODE -eq 0) {
        Write-Log "  [DONE] $RepoName" SUCCESS
    }
    else {
        $errorMsg = ($output -join "`n").Trim()
        Write-Log "  [FAIL] $RepoName`n$errorMsg" ERROR
    }
}
# -------------------------------------------------
#  8. Main: Process each project (Sequential)
# -------------------------------------------------
foreach ($proj in $projects) {
    Write-Log "`n=== Project: $proj ===" INFO
    $reposJson = az repos list --org $org --project $proj `
        --query "[].{Name:name, Url:remoteUrl}" -o json
    if ($LASTEXITCODE) {
        Write-Log "  Failed to list repos in $proj" WARN
        continue
    }
    $repos = $reposJson | ConvertFrom-Json
    if (-not $repos) {
        Write-Log "  No repositories in $proj" WARN
        continue
    }
    foreach ($repo in $repos) {
        $destPath = Join-Path $LocalFolder "$proj/$($repo.Name)"
        $projFolder = Split-Path $destPath -Parent
        New-Item -ItemType Directory -Force -Path $projFolder | Out-Null
        Clone-Repo -Project $proj -RepoName $repo.Name -RepoUrl $repo.Url -Destination $destPath
    }
}
# -------------------------------------------------
#  9. Finalize
# -------------------------------------------------
Write-Log "`nCloning complete! All repositories are in:" SUCCESS
Write-Host "    $LocalFolder" -ForegroundColor Cyan
Write-Log "Log file: $LogFilePath" INFO
# Close log stream
if ($LogStream) {
    $LogStream.Close()
    $LogStream.Dispose()
}
Check out the Azure CLI to learn more at: https://learn.microsoft.com/cli/azure/
Contributors
| Author(s) | 
|---|
| Harminder Singh | 
Disclaimer
THESE SAMPLES ARE PROVIDED AS IS WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.