Public/Invoke-PSUADORepoClone.ps1

function Invoke-PSUADORepoClone {
    <#
    .SYNOPSIS
        Clones all repositories from an Azure DevOps project to a local directory.
 
    .DESCRIPTION
        Clones all repositories from a specified Azure DevOps project using the Git CLI. The function will attempt to auto-detect
        the Organization from the current repository's remote (origin) or environment variables. Authentication uses a Personal
        Access Token (PAT) via the $env:PAT environment variable or the -PAT parameter. When a PAT is supplied, the function will
        create an authenticated HTTPS clone URL for each repository. If no PAT is supplied, it uses the repo's remoteUrl.
 
        This helper is intended for automation scripts and CI tasks where bulk cloning of project repositories is required.
 
    .PARAMETER Organization
        (Optional) The Azure DevOps organization name. Auto-detected from git remote origin URL, or uses $env:ORGANIZATION when set.
 
    .PARAMETER Project
        (Mandatory) The Azure DevOps project name containing repositories to clone.
 
    .PARAMETER TargetPath
        (Mandatory) Local folder to clone into. Will create a subdirectory named "{Project}-Repos" under this path.
 
    .PARAMETER PAT
        (Optional) Personal Access Token used for HTTPS authentication. Default is $env:PAT. Do NOT hardcode secrets.
 
    .PARAMETER RepositoryFilter
        (Optional) Wildcard pattern to filter repository names to clone, e.g. 'API-*'.
 
    .PARAMETER Force
        (Optional) Switch to remove existing target folder before cloning.
 
    .EXAMPLE
        Invoke-PSUADORepoClone -Organization "myorg" -Project "MyProject" -TargetPath "C:\repos" -PAT $env:PAT
 
        Clones all repositories from MyProject into C:\repos\MyProject-Repos folder using PAT for authentication.
 
    .EXAMPLE
        Invoke-PSUADORepoClone -Project "MyProject" -TargetPath "D:\code" -RepositoryFilter 'API-*'
 
        Auto-detects organization and clones only repositories matching the filter into D:\code\MyProject-Repos.
 
    .OUTPUTS
        [PSCustomObject]
 
    .NOTES
        Author: Lakshmanachari Panuganti
        Date: 28th August 2025
 
    .LINK
        https://github.com/lakshmanachari-panuganti/OMG.PSUtilities/tree/main/OMG.PSUtilities.AzureDevOps
        https://www.linkedin.com/in/lakshmanachari-panuganti/
        https://www.powershellgallery.com/packages/OMG.PSUtilities.AzureDevOps
        https://learn.microsoft.com/en-us/rest/api/azure/devops
    #>


    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Console UX: colorized status for user-facing output')]
    param (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Organization = $env:ORGANIZATION,
        
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Project,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({
                if (Test-Path $_) {
                    if (-not (Get-Item $_).PSIsContainer) { throw "TargetPath '$_' exists but is not a directory." }
                    return $true
                }
                $parent = Split-Path $_ -Parent
                if (-not $parent) { throw "TargetPath '$_' is not a valid path." }
                if (-not (Test-Path $parent)) { throw "Parent directory '$parent' does not exist. Create it first or provide a different TargetPath." }
                try { $tmp = Join-Path $parent ([System.IO.Path]::GetRandomFileName()); New-Item -Path $tmp -ItemType File -Force | Out-Null; Remove-Item -Path $tmp -Force; return $true } catch { throw "Cannot write to parent directory '$parent'. Check permissions." }
            })]
        [string]$TargetPath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$PAT = $env:PAT,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$RepositoryFilter,

        [Parameter()]
        [switch]$Force
    )

    process {
        $repoResults = @()
        Set-Location $TargetPath
        Write-Host "Setting the target path: $TargetPath"

        try {
            if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
                throw 'Git CLI not found. Please install Git and ensure it is available in PATH.'
            }

            $headers = Get-PSUAdoAuthHeader -PAT $PAT -ErrorAction Stop

            # Get Project ID
            $projUri = "https://dev.azure.com/$Organization/_apis/projects?api-version=7.1-preview.4"
            $projectsResp = Invoke-RestMethod -Uri $projUri -Headers $headers -Method Get -ErrorAction Stop
            $projectObj = $projectsResp.value | Where-Object { $_.name -eq $Project }

            if (-not $projectObj) {
                throw "Project '$Project' not found in organization '$Organization'."
            }

            $projectId = $projectObj.id

            # Get repositories
            $repoUri = "https://dev.azure.com/$Organization/$projectId/_apis/git/repositories?api-version=7.1-preview.1"
            Write-Host "Processing the project: '$Project'..." -ForegroundColor Cyan
            $repoResp = Invoke-RestMethod -Uri $repoUri -Headers $headers -Method Get -ErrorAction Stop
            $allRepos = $repoResp.value

            if (-not $allRepos -or $allRepos.Count -eq 0) {
                throw 'No repositories found in the project.'
            }

            # Apply filter if needed
            $repos = if ($RepositoryFilter) {
                $allRepos | Where-Object { $_.name -like $RepositoryFilter }
            }
            else {
                $allRepos
            }

            if (-not $repos) {
                throw "No repositories match the filter '$RepositoryFilter'."
            }

            # Prepare target folder
            $projectFolder = Join-Path -Path $TargetPath -ChildPath "$Project"
            if (Test-Path $projectFolder) {
                if ($Force) {
                    Remove-Item -LiteralPath $projectFolder -Recurse -Force -ErrorAction Stop
                }
                else {
                    throw "Target path '$projectFolder' already exists. Use -Force to remove it."
                }
            }
            New-Item -ItemType Directory -Path $projectFolder -Force | Out-Null

            # Clone or skip each repo
            foreach ($repo in $repos) {
                $repoName = $repo.name
                $cloneUrl = $repo.remoteUrl
                $clonedPath = Join-Path -Path $projectFolder -ChildPath $repoName
                $isCloned = $false
                $errorMsg = $null

                if ($repo.isDisabled) {
                    $errorMsg = "Repo disabled"
                }
                else {
                    Write-Host "Cloning $repoName..." -ForegroundColor Green
                    Set-Location $projectFolder
                    $gitOutput = & git clone $cloneUrl 2>&1
                    if ($LASTEXITCODE -eq 0) {
                        $isCloned = $true
                    }
                    else {
                        $errorMsg = "Git clone failed. Output: $gitOutput"
                        $clonedPath = $null
                    }
                }

                $repoResults += [PSCustomObject]@{
                    Organization = $Organization
                    Project      = $Project
                    Repository   = $repoName
                    isCloned     = $isCloned
                    PathCloned   = $clonedPath
                    Error        = $errorMsg
                    PSTypeName   = 'PSU.ADO.RepoCloneSummary'
                }
            }

            return $repoResults
        }
        catch {
            $repoResults += [PSCustomObject]@{
                Organization = $Organization
                Project      = $Project
                Repository   = $null
                isCloned     = $false
                PathCloned   = $null
                Error        = $_.Exception.Message
                PSTypeName   = 'PSU.ADO.RepoCloneSummary'
            }

            return $repoResults
        }
    }
}