|
1 | | -# Clone all repositories from Azure DevOps |
2 | | - |
3 | | -> [!Note] |
4 | | -> This is a submission helper template please find the [contributor guidance](/docfx/contribute.md) to help you write this scenario. |
| 1 | +# # Azure DevOps Repo Cloner (PowerShell & AzureCli) |
5 | 2 |
|
6 | 3 | ## Summary |
7 | 4 |
|
8 | | -Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. |
| 5 | +A **secure, user-friendly PowerShell script** to clone **all accessible Git repositories** from an Azure DevOps organization — **no admin rights required**. |
9 | 6 |
|
10 | | - |
| 7 | +## Features |
11 | 8 |
|
12 | | -Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna.Nunc viverra imperdiet enim. Fusce est. Vivamus a tellus.Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Proin pharetra nonummy pede. Mauris et orci.Aenean nec lorem. In porttitor. Donec laoreet nonummy augue. |
| 9 | +- **Interactive prompts** for: |
| 10 | + - Organization URL |
| 11 | + - Local clone folder |
| 12 | + - **Log file path** (auto-creates `.log`, validates write access) |
| 13 | +- **Logs to both console and file** with timestamps and color-coding |
| 14 | +- **Only clones repos the user has access to** — respects Azure DevOps permissions |
| 15 | +- **Skips already cloned repos** — safe to re-run |
| 16 | +- **No local admin rights needed** — works for developers, contributors, externals |
| 17 | +- **Supports PAT or browser login** (`$env:AZURE_DEVOPS_EXT_PAT`) |
| 18 | +- **Robust error handling** — never crashes on permission issues |
13 | 19 |
|
14 | | -# [Azure CLI](#tab/azure-cli) |
| 20 | +## Requirements |
15 | 21 |
|
16 | | -```powershell |
| 22 | +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`) |
| 23 | +- `azure-devops` extension (`az extension add --name azure-devops`) |
| 24 | +- Git (`git`) |
| 25 | +- PowerShell 5.1+ (7+ recommended) |
17 | 26 |
|
18 | | -<your script> |
| 27 | +--- |
19 | 28 |
|
20 | | -``` |
21 | | -[!INCLUDE [More about Azure CLI](../../docfx/includes/MORE-AZURECLI.md)] |
| 29 | +## How to Use |
| 30 | + |
| 31 | +1. **Save** the script as `Clone-All-DevOpsRepos.ps1` |
| 32 | +2. **Run in PowerShell**: |
| 33 | + |
| 34 | +````powershell |
| 35 | +.\Clone-All-DevOpsRepos.ps1 |
22 | 36 |
|
23 | 37 | # [Azure CLI](#tab/azure-cli) |
24 | 38 |
|
25 | 39 | ```powershell |
26 | 40 |
|
27 | | -<your script> |
| 41 | +<# |
| 42 | +.SYNOPSIS |
| 43 | + Clone all Azure DevOps Git repositories from every project. |
| 44 | +
|
| 45 | +.DESCRIPTION |
| 46 | + Interactive or non-interactive (PAT). Organizes repos as: |
| 47 | + <LocalFolder>/<Project>/<Repo> |
| 48 | + Logs to both console and a user-specified log file (with .log auto-added). |
| 49 | +
|
| 50 | +.NOTES |
| 51 | + • Requires: Azure CLI + `azure-devops` extension |
| 52 | + • Use $env:AZURE_DEVOPS_EXT_PAT for CI/CD |
| 53 | + • PowerShell 7+ recommended |
| 54 | +
|
| 55 | +.EXAMPLE |
| 56 | + .\Clone-All-DevOpsRepos.ps1 |
| 57 | +
|
| 58 | + # Prompts for: |
| 59 | + # Organization URL |
| 60 | + # Local folder path |
| 61 | + # Log file path (e.g. C:\Logs\clone.log) |
| 62 | +#> |
| 63 | +
|
| 64 | +[CmdletBinding()] |
| 65 | +param() |
| 66 | +
|
| 67 | +# Global log stream and path |
| 68 | +$LogStream = $null |
| 69 | +$LogFilePath = $null |
| 70 | +
|
| 71 | +# ------------------------------------------------- |
| 72 | +# Helper: Colored, timestamped logging (console + file) |
| 73 | +# ------------------------------------------------- |
| 74 | +function Write-Log { |
| 75 | + param( |
| 76 | + [string]$Message, |
| 77 | + [ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS')][string]$Level = 'INFO' |
| 78 | + ) |
| 79 | + $timestamp = Get-Date -Format "HH:mm:ss" |
| 80 | + $logLine = "[$timestamp] [$Level] $Message" |
| 81 | +
|
| 82 | + # Console output with color |
| 83 | + $color = switch ($Level) { |
| 84 | + 'INFO' { 'White' } |
| 85 | + 'WARN' { 'Yellow' } |
| 86 | + 'ERROR' { 'Red' } |
| 87 | + 'SUCCESS' { 'Green' } |
| 88 | + } |
| 89 | + Write-Host $logLine -ForegroundColor $color |
| 90 | +
|
| 91 | + # Write to file |
| 92 | + if ($LogStream) { |
| 93 | + try { $LogStream.WriteLine($logLine) } catch { } |
| 94 | + } |
| 95 | +} |
| 96 | +
|
| 97 | +# ------------------------------------------------- |
| 98 | +# 1. Get Organization URL |
| 99 | +# ------------------------------------------------- |
| 100 | +do { |
| 101 | + $org = Read-Host "Enter Azure DevOps organization URL (e.g. https://dev.azure.com/contoso)" |
| 102 | + $org = $org.Trim() |
| 103 | + if (-not $org) { Write-Host "Organization URL cannot be empty." -ForegroundColor Red } |
| 104 | +} while (-not $org) |
| 105 | +
|
| 106 | +Write-Log "Using organization: $org" SUCCESS |
| 107 | +
|
| 108 | +# ------------------------------------------------- |
| 109 | +# 2. Get Local Clone Folder |
| 110 | +# ------------------------------------------------- |
| 111 | +do { |
| 112 | + $folder = Read-Host "Enter local folder to clone repos into (e.g. C:\Repos or ./backup)" |
| 113 | + $folder = $folder.Trim() |
| 114 | + if (-not $folder) { Write-Host "Folder path cannot be empty." -ForegroundColor Red; continue } |
| 115 | +
|
| 116 | + try { |
| 117 | + $resolved = Resolve-Path -Path $folder -ErrorAction Stop |
| 118 | + $LocalFolder = $resolved.Path |
| 119 | + break |
| 120 | + } |
| 121 | + catch { |
| 122 | + try { |
| 123 | + New-Item -ItemType Directory -Path $folder -Force | Out-Null |
| 124 | + $LocalFolder = (Resolve-Path -Path $folder).Path |
| 125 | + break |
| 126 | + } |
| 127 | + catch { |
| 128 | + Write-Host "Invalid or inaccessible path: $folder" -ForegroundColor Red |
| 129 | + } |
| 130 | + } |
| 131 | +} while ($true) |
| 132 | +
|
| 133 | +Write-Log "Cloning into: $LocalFolder" SUCCESS |
| 134 | +
|
| 135 | +# ------------------------------------------------- |
| 136 | +# 3. Get Log FILE Path (with .log auto-add) |
| 137 | +# ------------------------------------------------- |
| 138 | +do { |
| 139 | + Write-Host "`nEnter FULL PATH for LOG FILE (e.g. C:\Logs\clone.log or ./clone.log)" -ForegroundColor Cyan |
| 140 | + $logInput = Read-Host "Log file path" |
| 141 | + $logInput = $logInput.Trim() |
| 142 | + if (-not $logInput) { |
| 143 | + Write-Host "Log file path is required." -ForegroundColor Red |
| 144 | + continue |
| 145 | + } |
| 146 | +
|
| 147 | + # Auto-add .log if missing |
| 148 | + if (-not $logInput.EndsWith('.log', [System.StringComparison]::OrdinalIgnoreCase)) { |
| 149 | + $logInput = "$logInput.log" |
| 150 | + Write-Host "Auto-added .log → $logInput" -ForegroundColor DarkGray |
| 151 | + } |
| 152 | +
|
| 153 | + $logDir = Split-Path $logInput -Parent |
| 154 | + if (-not $logDir) { $logDir = "." } |
| 155 | +
|
| 156 | + try { |
| 157 | + # Ensure directory exists |
| 158 | + if (-not (Test-Path $logDir)) { |
| 159 | + New-Item -ItemType Directory -Path $logDir -Force | Out-Null |
| 160 | + Write-Log "Created log directory: $logDir" INFO |
| 161 | + } |
| 162 | +
|
| 163 | + # Test write access |
| 164 | + $testFile = Join-Path $logDir "log_$(Get-Random).tmp" |
| 165 | + "log" | Out-File $testFile -Force -Encoding utf8 |
| 166 | + Remove-Item $testFile -Force |
| 167 | +
|
| 168 | + # Resolve full path |
| 169 | + $LogFilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($logInput) |
| 170 | +
|
| 171 | + # Open stream for appending |
| 172 | + $LogStream = [System.IO.StreamWriter]::new($LogFilePath, $true, [System.Text.Encoding]::UTF8) |
| 173 | + $LogStream.AutoFlush = $true |
| 174 | +
|
| 175 | + Write-Log "Logging enabled to: $LogFilePath" SUCCESS |
| 176 | + break |
| 177 | + } |
| 178 | + catch { |
| 179 | + Write-Host "Cannot write to: $logInput" -ForegroundColor Red |
| 180 | + Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red |
| 181 | + if ($_.Exception.Message -like "*Access*denied*") { |
| 182 | + Write-Host "Tip: Run as Administrator, or use a user-writable folder (e.g. Documents)." -ForegroundColor Yellow |
| 183 | + } |
| 184 | + } |
| 185 | +} while ($true) |
| 186 | +
|
| 187 | +# ------------------------------------------------- |
| 188 | +# 4. Ensure Azure CLI + extension |
| 189 | +# ------------------------------------------------- |
| 190 | +Write-Log "Installing azure-devops extension..." INFO |
| 191 | +az extension add --name azure-devops --yes | Out-Null |
| 192 | +if ($LASTEXITCODE) { Write-Log "Failed to install extension" ERROR; exit 1 } |
| 193 | +
|
| 194 | +# ------------------------------------------------- |
| 195 | +# 5. Authenticate |
| 196 | +# ------------------------------------------------- |
| 197 | +Write-Log "Authenticating to Azure DevOps..." INFO |
| 198 | +if (-not $env:AZURE_DEVOPS_EXT_PAT) { |
| 199 | + Write-Host "Opening browser for login (close when done)..." -ForegroundColor Cyan |
| 200 | + az login --allow-no-subscriptions | Out-Null |
| 201 | + if ($LASTEXITCODE) { Write-Log "Login failed" ERROR; exit 1 } |
| 202 | +} |
| 203 | +az devops configure --defaults organization=$org | Out-Null |
| 204 | +if ($LASTEXITCODE) { Write-Log "Failed to set default org" ERROR; exit 1 } |
| 205 | +
|
| 206 | +# ------------------------------------------------- |
| 207 | +# 6. Get all projects |
| 208 | +# ------------------------------------------------- |
| 209 | +Write-Log "Fetching projects..." INFO |
| 210 | +$projectsJson = az devops project list --organization $org -o json |
| 211 | +if ($LASTEXITCODE) { Write-Log "Failed to list projects" ERROR; exit 1 } |
| 212 | +
|
| 213 | +$projects = ($projectsJson | ConvertFrom-Json).value | Select-Object -ExpandProperty name |
| 214 | +if (-not $projects) { |
| 215 | + Write-Log "No projects found. Check URL and permissions." ERROR |
| 216 | + if ($LogStream) { $LogStream.Close(); $LogStream.Dispose() } |
| 217 | + exit 1 |
| 218 | +} |
| 219 | +
|
| 220 | +Write-Log "Found $($projects.Count) project(s): $($projects -join ', ')" SUCCESS |
| 221 | +
|
| 222 | +# ------------------------------------------------- |
| 223 | +# 7. Function: Clone a single repo |
| 224 | +# ------------------------------------------------- |
| 225 | +function Clone-Repo { |
| 226 | + param($Project, $RepoName, $RepoUrl, $Destination) |
| 227 | +
|
| 228 | + if (Test-Path $Destination) { |
| 229 | + Write-Log " [SKIP] $RepoName (already exists)" WARN |
| 230 | + return |
| 231 | + } |
| 232 | +
|
| 233 | + Write-Log " [CLONE] $RepoName ..." INFO |
| 234 | + $output = git clone $RepoUrl $Destination 2>&1 |
| 235 | + if ($LASTEXITCODE -eq 0) { |
| 236 | + Write-Log " [DONE] $RepoName" SUCCESS |
| 237 | + } |
| 238 | + else { |
| 239 | + $errorMsg = ($output -join "`n").Trim() |
| 240 | + Write-Log " [FAIL] $RepoName`n$errorMsg" ERROR |
| 241 | + } |
| 242 | +} |
| 243 | +
|
| 244 | +# ------------------------------------------------- |
| 245 | +# 8. Main: Process each project (Sequential) |
| 246 | +# ------------------------------------------------- |
| 247 | +foreach ($proj in $projects) { |
| 248 | + Write-Log "`n=== Project: $proj ===" INFO |
| 249 | +
|
| 250 | + $reposJson = az repos list --org $org --project $proj ` |
| 251 | + --query "[].{Name:name, Url:remoteUrl}" -o json |
| 252 | +
|
| 253 | + if ($LASTEXITCODE) { |
| 254 | + Write-Log " Failed to list repos in $proj" WARN |
| 255 | + continue |
| 256 | + } |
| 257 | +
|
| 258 | + $repos = $reposJson | ConvertFrom-Json |
| 259 | + if (-not $repos) { |
| 260 | + Write-Log " No repositories in $proj" WARN |
| 261 | + continue |
| 262 | + } |
| 263 | +
|
| 264 | + foreach ($repo in $repos) { |
| 265 | + $destPath = Join-Path $LocalFolder "$proj/$($repo.Name)" |
| 266 | + $projFolder = Split-Path $destPath -Parent |
| 267 | + New-Item -ItemType Directory -Force -Path $projFolder | Out-Null |
| 268 | +
|
| 269 | + Clone-Repo -Project $proj -RepoName $repo.Name -RepoUrl $repo.Url -Destination $destPath |
| 270 | + } |
| 271 | +} |
| 272 | +
|
| 273 | +# ------------------------------------------------- |
| 274 | +# 9. Finalize |
| 275 | +# ------------------------------------------------- |
| 276 | +Write-Log "`nCloning complete! All repositories are in:" SUCCESS |
| 277 | +Write-Host " $LocalFolder" -ForegroundColor Cyan |
| 278 | +Write-Log "Log file: $LogFilePath" INFO |
| 279 | +
|
| 280 | +# Close log stream |
| 281 | +if ($LogStream) { |
| 282 | + $LogStream.Close() |
| 283 | + $LogStream.Dispose() |
| 284 | +} |
| 285 | +
|
28 | 286 |
|
29 | | -``` |
30 | | -[!INCLUDE [More about Azure CLI](../../docfx/includes/MORE-AZURECLI.md)] |
31 | | -*** |
| 287 | +```` |
32 | 288 |
|
| 289 | +--- |
33 | 290 |
|
34 | 291 | ## Contributors |
35 | 292 |
|
36 | | -| Author(s) | |
37 | | -|-----------| |
| 293 | +| Author(s) | |
| 294 | +| --------------- | |
38 | 295 | | Harminder Singh | |
39 | 296 |
|
40 | 297 | [!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)] |
|
0 commit comments