Public/Update-AllWorktrees.ps1

function Expand-UpdateAllWorktreesPattern {
    <#
    .SYNOPSIS
        Expand string-array wildcard arguments that may contain comma-separated values.
    #>

    [CmdletBinding()]
    param([string[]]$Pattern)

    foreach ($entry in $Pattern) {
        foreach ($part in ($entry -split ',')) {
            $trimmed = $part.Trim()
            if ($trimmed) { $trimmed }
        }
    }
}

function Test-UpdateAllWorktreesPattern {
    <#
    .SYNOPSIS
        Test whether any wildcard pattern matches any candidate value.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string[]]$Candidate,
        [string[]]$Pattern
    )

    if (-not $Pattern -or $Pattern.Count -eq 0) { return $true }

    foreach ($candidateValue in $Candidate) {
        foreach ($patternValue in $Pattern) {
            if ($candidateValue -like $patternValue) { return $true }
        }
    }

    $false
}

function Get-UpdateAllWorktreesRepository {
    <#
    .SYNOPSIS
        Discover git working trees below a repository root and group them by org/repo.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Root,
        [string[]]$Organization,
        [string[]]$Name,
        [string[]]$Exclude
    )

    $resolvedRoot = (Resolve-Path -LiteralPath $Root -ErrorAction Stop).Path
    $rootDirectory = Get-Item -LiteralPath $resolvedRoot -ErrorAction Stop
    $pending = [System.Collections.Generic.Stack[System.IO.DirectoryInfo]]::new()
    $pending.Push($rootDirectory)
    $repositories = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)

    while ($pending.Count -gt 0) {
        $directory = $pending.Pop()
        $gitPath = Join-Path $directory.FullName '.git'

        if (Test-Path -LiteralPath $gitPath) {
            $relativePath = [System.IO.Path]::GetRelativePath($resolvedRoot, $directory.FullName)
            $segments = if ($relativePath -eq '.') {
                @()
            } else {
                @($relativePath -split "[/\\]+" | Where-Object { $_ })
            }

            if ($segments.Count -ge 2) {
                $org = $segments[0]
                $repo = $segments[1]
            } elseif ($segments.Count -eq 1) {
                $org = Split-Path -Leaf $resolvedRoot
                $repo = $segments[0]
            } else {
                $org = Split-Path -Leaf (Split-Path -Parent $directory.FullName)
                $repo = Split-Path -Leaf $directory.FullName
            }

            $key = "$org/$repo"
            if (-not $repositories.ContainsKey($key)) {
                $repositories[$key] = [PSCustomObject]@{
                    PSTypeName   = 'AllWorktreesRepository'
                    Organization = $org
                    Repository   = $repo
                    Path         = $directory.FullName
                }
            }

            continue
        }

        foreach ($child in Get-ChildItem -LiteralPath $directory.FullName -Directory -Force -ErrorAction SilentlyContinue) {
            if ($child.Name -eq '.git') { continue }
            $pending.Push($child)
        }
    }

    $orgPatterns = @(Expand-UpdateAllWorktreesPattern $Organization)
    $namePatterns = @(Expand-UpdateAllWorktreesPattern $Name)
    $excludePatterns = @(Expand-UpdateAllWorktreesPattern $Exclude)

    $repositories.Values |
        Where-Object { Test-UpdateAllWorktreesPattern -Candidate @($_.Organization) -Pattern $orgPatterns } |
        Where-Object { Test-UpdateAllWorktreesPattern -Candidate @($_.Repository) -Pattern $namePatterns } |
        Where-Object {
            $excludePatterns.Count -eq 0 -or
                -not (Test-UpdateAllWorktreesPattern -Candidate @($_.Organization, $_.Repository, "$($_.Organization)/$($_.Repository)") -Pattern $excludePatterns)
        } |
        Sort-Object Organization, Repository
}

function Update-AllWorktrees {
    <#
    .SYNOPSIS
    Update every discovered repository under a standard repos root.
    .DESCRIPTION
    Discovers git repositories under a root directory using the standard
    `<root>/<org>/<repo>/<branch>` worktree layout, then updates each repository
    in parallel by importing this module in each worker runspace and invoking
    Update-Worktrees. Returns one AllWorktreesUpdateResult object per repository
    with the organization, repository name, selected path, overall status, and
    the underlying WorktreeUpdateResult objects.
 
    The default root is `$env:SOURCE_REPOS` when it is set; otherwise the current
    directory is used. A missing root produces a clear non-terminating error and
    no repository is updated.
    .PARAMETER Path
    Root directory to scan. Defaults to `$env:SOURCE_REPOS`, then the current
    directory. Alias: Root.
    .PARAMETER Organization
    Organization folder filter(s). Values are wildcard matched and may be passed
    as an array or comma-separated list.
    .PARAMETER Name
    Repository name filter(s). Values are wildcard matched and may be passed as
    an array or comma-separated list.
    .PARAMETER Exclude
    Exclusion filter(s). Values are wildcard matched against organization,
    repository, and `organization/repository`, and may be passed as an array or
    comma-separated list.
    .PARAMETER ThrottleLimit
    Maximum number of repositories to update in parallel. Defaults to 4.
    .PARAMETER CheckRemote
    Forwarded to Update-Worktrees for each repository.
    .PARAMETER GitHubAccountMap
    Forwarded to Update-Worktrees for each repository.
    .PARAMETER GitHubAccountResolver
    Forwarded to Update-Worktrees for each repository.
    .PARAMETER NoGitHubAccountResolve
    Forwarded to Update-Worktrees for each repository.
    .EXAMPLE
    Update-AllWorktrees
    Updates every repository under `$env:SOURCE_REPOS`, or the current directory
    when that environment variable is not set.
    .EXAMPLE
    Update-AllWorktrees -Path ~/src -Organization microsoft,shmuelie -Name 'powershell-*'
    Updates matching repositories below `~/src`.
    .EXAMPLE
    Update-AllWorktrees -Exclude 'archive/*','*-old' -ThrottleLimit 8
    Updates repositories in parallel except excluded organization/repository or
    repository-name matches.
    .EXAMPLE
    Update-AllWorktrees -Path ~/src -WhatIf
    Shows which repositories would be updated without fetching or merging.
    #>

    [OutputType('AllWorktreesUpdateResult')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Alias('Root')]
        [string]$Path,

        [string[]]$Organization,

        [string[]]$Name,

        [string[]]$Exclude,

        [ValidateRange(1, 128)]
        [int]$ThrottleLimit = 4,

        [switch]$CheckRemote,

        [hashtable]$GitHubAccountMap,

        [scriptblock]$GitHubAccountResolver,

        [switch]$NoGitHubAccountResolve
    )

    if (-not $Path) {
        $Path = if ($env:SOURCE_REPOS) { $env:SOURCE_REPOS } else { (Get-Location).Path }
    }

    if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
        Write-Error "Repository root not found: $Path"
        return
    }

    $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'Shmuelie.Git.psd1'
    $repositories = @(Get-UpdateAllWorktreesRepository -Root $Path -Organization $Organization -Name $Name -Exclude $Exclude)
    if ($repositories.Count -eq 0) { return }

    $whatIfResults = [System.Collections.Generic.List[object]]::new()
    $repositoriesToUpdate = [System.Collections.Generic.List[object]]::new()

    foreach ($repository in $repositories) {
        if ($PSCmdlet.ShouldProcess("$($repository.Organization)/$($repository.Repository)", 'Update all worktrees')) {
            $repositoriesToUpdate.Add($repository)
        } else {
            $whatIfResults.Add([PSCustomObject]@{
                PSTypeName      = 'AllWorktreesUpdateResult'
                Organization    = $repository.Organization
                Repository      = $repository.Repository
                Path            = $repository.Path
                Status          = 'WhatIf'
                WorktreeResults = @()
                Error           = $null
            })
        }
    }

    foreach ($result in $whatIfResults) { $result }
    if ($repositoriesToUpdate.Count -eq 0) { return }

    $forwardCheckRemote = $CheckRemote.IsPresent
    $forwardGitHubAccountMap = $PSBoundParameters.ContainsKey('GitHubAccountMap')
    $forwardGitHubAccountResolver = $PSBoundParameters.ContainsKey('GitHubAccountResolver')
    $forwardNoGitHubAccountResolve = $NoGitHubAccountResolve.IsPresent
    $accountMap = $GitHubAccountMap
    $accountResolver = $GitHubAccountResolver

    $repositoriesToUpdate | ForEach-Object -Parallel {
        $repository = $_
        $errors = @()

        try {
            Import-Module $using:modulePath -Force -ErrorAction Stop

            $updateParams = @{ Path = $repository.Path }
            if ($using:forwardCheckRemote) { $updateParams.CheckRemote = $true }
            if ($using:forwardGitHubAccountMap) { $updateParams.GitHubAccountMap = $using:accountMap }
            if ($using:forwardGitHubAccountResolver) { $updateParams.GitHubAccountResolver = $using:accountResolver }
            if ($using:forwardNoGitHubAccountResolve) { $updateParams.NoGitHubAccountResolve = $true }

            $updateErrors = @()
            $worktreeResults = @(Update-Worktrees @updateParams -ErrorAction SilentlyContinue -ErrorVariable updateErrors)
            foreach ($updateError in $updateErrors) { $errors += $updateError.Exception.Message }

            $failedWorktrees = @($worktreeResults | Where-Object { $_.Status -in @('Failed', 'StashFailed') })
            foreach ($failedWorktree in $failedWorktrees) {
                $errors += "Worktree '$($failedWorktree.Branch)' reported status '$($failedWorktree.Status)'."
            }

            [PSCustomObject]@{
                PSTypeName      = 'AllWorktreesUpdateResult'
                Organization    = $repository.Organization
                Repository      = $repository.Repository
                Path            = $repository.Path
                Status          = if ($errors.Count -gt 0) { 'Failed' } else { 'Completed' }
                WorktreeResults = $worktreeResults
                Error           = if ($errors.Count -gt 0) { $errors -join [Environment]::NewLine } else { $null }
            }
        } catch {
            [PSCustomObject]@{
                PSTypeName      = 'AllWorktreesUpdateResult'
                Organization    = $repository.Organization
                Repository      = $repository.Repository
                Path            = $repository.Path
                Status          = 'Failed'
                WorktreeResults = @()
                Error           = $_.Exception.Message
            }
        }
    } -ThrottleLimit $ThrottleLimit
}