src/PowerShell/StatusBatch.ps1
|
# cdp PowerShell domain: StatusBatch.ps1 # Loaded by src/cdp.psm1; do not dot-source peer files. function Get-CdpStatusIntegerSetting { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][int]$Default, [Parameter(Mandatory = $true)][int]$Minimum, [Parameter(Mandatory = $true)][int]$Maximum ) $parsed = 0 $raw = [Environment]::GetEnvironmentVariable($Name) if (-not [int]::TryParse($raw, [ref]$parsed)) { $parsed = $Default } [Math]::Max($Minimum, [Math]::Min($Maximum, $parsed)) } function Resolve-CdpStatusThrottleLimit { param([int]$Value = 0) if ($Value -gt 0) { return [Math]::Max(1, [Math]::Min(16, $Value)) } $default = [Math]::Max(1, [Math]::Min(4, [Environment]::ProcessorCount)) Get-CdpStatusIntegerSetting -Name 'CDP_STATUS_CONCURRENCY' -Default $default -Minimum 1 -Maximum 16 } function Get-CdpStatusCacheKey { param([Parameter(Mandatory = $true)][object]$Project) $name = ([string]$Project.name).ToUpperInvariant() $resolution = Resolve-CdpProjectPath -Project $Project if ($resolution.ErrorCode) { return "$name`0$($resolution.Profile)`0invalid:$($resolution.Source):$($resolution.RawPath)" } $path = Get-CdpComparablePath -Path ([string]$resolution.ResolvedPath) "$name`0$($resolution.Profile)`0$path" } function Get-CdpCachedGitProjectInfo { param( [Parameter(Mandatory = $true)][object]$Project, [Parameter(Mandatory = $true)][int]$TtlSeconds, [switch]$Refresh ) $key = Get-CdpStatusCacheKey -Project $Project if ($Refresh) { [void]$script:CdpStatusCache.Remove($key); return $null } if ($TtlSeconds -le 0) { return $null } $entry = $script:CdpStatusCache[$key] if (-not $entry) { return $null } if (([DateTime]::UtcNow - $entry.CachedAt).TotalSeconds -ge $TtlSeconds) { [void]$script:CdpStatusCache.Remove($key) return $null } $entry.Value } function Set-CdpCachedGitProjectInfo { param( [Parameter(Mandatory = $true)][object]$Project, [Parameter(Mandatory = $true)][object]$Info, [Parameter(Mandatory = $true)][int]$TtlSeconds ) if ($TtlSeconds -le 0) { return } $script:CdpStatusCache[(Get-CdpStatusCacheKey -Project $Project)] = [PSCustomObject]@{ CachedAt = [DateTime]::UtcNow Value = $Info } } function New-CdpFailedGitProjectInfo { param( [Parameter(Mandatory = $true)][object]$Project, [Parameter(Mandatory = $true)][string]$StatusLabel ) $info = New-CdpGitProjectInfo -Project $Project if (-not $info.PathResolutionError) { $info.PathExists = Test-Path -LiteralPath ([string]$info.ResolvedPath) } $info.StatusLabel = $StatusLabel $info.NeedsAttention = $true $info } function Get-CdpStatusWorkerHelperScript { @( 'function New-CdpGitProjectInfo {' (Get-Command New-CdpGitProjectInfo).ScriptBlock.ToString() '}' 'function Convert-WindowsPathToWSL {' (Get-Command Convert-WindowsPathToWSL).ScriptBlock.ToString() '}' 'function Get-CdpDetectedPathProfile {' (Get-Command Get-CdpDetectedPathProfile).ScriptBlock.ToString() '}' 'function Get-CdpCurrentPathProfile {' (Get-Command Get-CdpCurrentPathProfile).ScriptBlock.ToString() '}' 'function Get-CdpProjectPathProperty {' (Get-Command Get-CdpProjectPathProperty).ScriptBlock.ToString() '}' 'function New-CdpPathResolution {' (Get-Command New-CdpPathResolution).ScriptBlock.ToString() '}' 'function Get-CdpInvalidKnownPathProfile {' (Get-Command Get-CdpInvalidKnownPathProfile).ScriptBlock.ToString() '}' 'function Resolve-CdpProjectPath {' (Get-Command Resolve-CdpProjectPath).ScriptBlock.ToString() '}' 'function ConvertFrom-CdpGitStatusPorcelainV2 {' (Get-Command ConvertFrom-CdpGitStatusPorcelainV2).ScriptBlock.ToString() '}' 'function Set-CdpGitProjectStatusLabel {' (Get-Command Set-CdpGitProjectStatusLabel).ScriptBlock.ToString() '}' 'function Get-CdpGitProjectInfo {' (Get-Command Get-CdpGitProjectInfo).ScriptBlock.ToString() '}' ) -join "`n" } function Start-CdpStatusWorker { param( [Parameter(Mandatory = $true)][object]$Project, [Parameter(Mandatory = $true)][System.Management.Automation.Runspaces.RunspacePool]$Pool, [Parameter(Mandatory = $true)][string]$HelperScript, [Parameter(Mandatory = $true)][scriptblock]$CollectorScript ) $pipeline = [PowerShell]::Create() $pipeline.RunspacePool = $Pool $workerScript = { param($Project, $HelperScript, $CollectorText) # Helper text is assembled only from trusted module-owned function bodies. Invoke-Expression $HelperScript & ([scriptblock]::Create($CollectorText)) $Project } [void]$pipeline.AddScript($workerScript.ToString()) [void]$pipeline.AddArgument($Project) [void]$pipeline.AddArgument($HelperScript) [void]$pipeline.AddArgument($CollectorScript.ToString()) [PSCustomObject]@{ Project = $Project PowerShell = $pipeline Async = $pipeline.BeginInvoke() } } function Complete-CdpStatusWorker { param( [Parameter(Mandatory = $true)][object]$Task, [Parameter(Mandatory = $true)][DateTime]$Deadline, [Parameter(Mandatory = $true)][int]$TtlSeconds ) try { $remaining = [Math]::Max(0, [int][Math]::Ceiling(($Deadline - [DateTime]::UtcNow).TotalMilliseconds)) if (-not $Task.Async.AsyncWaitHandle.WaitOne($remaining)) { try { $Task.PowerShell.Stop() } catch {} $info = New-CdpFailedGitProjectInfo -Project $Task.Project -StatusLabel 'status timed out' } else { try { $values = @($Task.PowerShell.EndInvoke($Task.Async)) $info = if ($values.Count -gt 0) { $values[-1] } else { New-CdpFailedGitProjectInfo -Project $Task.Project -StatusLabel 'status failed' } } catch { $info = New-CdpFailedGitProjectInfo -Project $Task.Project -StatusLabel 'status failed' } } Set-CdpCachedGitProjectInfo -Project $Task.Project -Info $info -TtlSeconds $TtlSeconds $info } finally { $Task.PowerShell.Dispose() } } function Get-CdpGitProjectInfoBatch { param( [Parameter(Mandatory = $true)][object[]]$Projects, [int]$ThrottleLimit = 0, [int]$TimeoutSeconds = 0, [switch]$Refresh, [scriptblock]$CollectorScript = { param($Project) Get-CdpGitProjectInfo -Project $Project } ) if ($Projects.Count -eq 0) { return } $limit = Resolve-CdpStatusThrottleLimit -Value $ThrottleLimit if ($TimeoutSeconds -le 0) { $TimeoutSeconds = Get-CdpStatusIntegerSetting -Name 'CDP_STATUS_TIMEOUT_SECONDS' -Default 10 -Minimum 1 -Maximum 60 } $ttl = Get-CdpStatusIntegerSetting -Name 'CDP_STATUS_CACHE_TTL' -Default 0 -Minimum 0 -Maximum 60 $pool = [RunspaceFactory]::CreateRunspacePool(1, [Math]::Min($limit, $Projects.Count)) try { $pool.Open() $helperScript = Get-CdpStatusWorkerHelperScript for ($offset = 0; $offset -lt $Projects.Count; $offset += $limit) { $tasks = New-Object 'System.Collections.Generic.List[object]' $end = [Math]::Min($Projects.Count, $offset + $limit) for ($index = $offset; $index -lt $end; $index++) { $project = $Projects[$index] $cached = Get-CdpCachedGitProjectInfo -Project $project -TtlSeconds $ttl -Refresh:$Refresh if ($cached) { $tasks.Add([PSCustomObject]@{ Project = $project; Cached = $cached }) } else { $tasks.Add((Start-CdpStatusWorker -Project $project -Pool $pool ` -HelperScript $helperScript -CollectorScript $CollectorScript)) } } $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) foreach ($task in $tasks) { if ($task.Cached) { $task.Cached } else { Complete-CdpStatusWorker -Task $task -Deadline $deadline -TtlSeconds $ttl } } } } finally { $pool.Dispose() } } function ConvertTo-CdpStatusRemoteDisplayUrl { param([string]$RemoteUrl) if ($RemoteUrl -notmatch '^https?://') { return $RemoteUrl } ($RemoteUrl -replace '([?#]).*$', '') -replace '^(https?://)[^/@]+@', '$1***@' } function Update-CdpStatusRemoteIdentity { param([Parameter(Mandatory = $true)][object]$Info) $path = [string]$Info.ResolvedPath $branch = @(& git -C $path symbolic-ref --quiet --short HEAD 2>$null) -join '' $Info.HeadOid = @(& git -C $path rev-parse HEAD 2>$null) -join '' $Info.RemoteName = '' $Info.RemoteRef = '' $Info.RemoteUrl = '' if (-not $branch) { $Info.Freshness = 'no-upstream'; return } $Info.RemoteName = @(& git -C $path config --get "branch.$branch.remote" 2>$null) -join '' $Info.RemoteRef = @(& git -C $path config --get "branch.$branch.merge" 2>$null) -join '' $Info.Remote = $Info.RemoteName if (-not $Info.RemoteName -or -not $Info.RemoteRef) { $Info.Freshness = 'no-upstream' return } $Info.Upstream = @(& git -C $path rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>$null) -join '' if (-not $Info.Upstream) { $Info.Upstream = "$($Info.RemoteName)/$($Info.RemoteRef -replace '^refs/heads/', '')" } if ($Info.RemoteName -ne '.') { $url = @(& git -C $path remote get-url $Info.RemoteName 2>$null) -join '' $Info.RemoteUrl = ConvertTo-CdpStatusRemoteDisplayUrl -RemoteUrl $url } if ($Info.Freshness -in @('not-applicable', 'no-upstream')) { $Info.Freshness = 'cached' } } function Update-CdpStatusSyncSnapshot { param([Parameter(Mandatory = $true)][object]$Info) $Info.AheadCount = 0 $Info.BehindCount = 0 $lines = @(& git -C $Info.ResolvedPath status --porcelain=v2 --branch --untracked-files=no 2>$null) if ($LASTEXITCODE -eq 0) { foreach ($line in $lines) { if ($line -match '^# branch\.ab \+(\d+) -(\d+)$') { $Info.AheadCount = [int]$matches[1] $Info.BehindCount = [int]$matches[2] } } } Update-CdpStatusRemoteIdentity -Info $Info Set-CdpGitProjectStatusLabel -Info $Info } function New-CdpStatusFetchResult { param([object]$Status, [string]$Freshness, [bool]$Succeeded, [bool]$TimedOut, [string]$Message, [object]$Process) [PSCustomObject]@{ Status=$Status; Freshness=$Freshness; FetchSucceeded=$Succeeded FetchTimedOut=$TimedOut; FetchMessage=$Message; Process=$Process } } function Start-CdpStatusFetchProcess { param([Parameter(Mandatory = $true)][object]$Status) $gitCommand = Get-Command git -CommandType Application -ErrorAction Stop | Select-Object -First 1 $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $gitCommand.Source $startInfo.WorkingDirectory = [string]$Status.ResolvedPath $startInfo.Arguments = 'fetch --quiet --prune --no-tags --no-recurse-submodules' $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.EnvironmentVariables['GIT_TERMINAL_PROMPT'] = '0' $startInfo.EnvironmentVariables['GCM_INTERACTIVE'] = 'Never' $startInfo.EnvironmentVariables['SSH_ASKPASS_REQUIRE'] = 'never' $process = New-Object System.Diagnostics.Process $process.StartInfo = $startInfo [void]$process.Start() $process.BeginOutputReadLine() $process.BeginErrorReadLine() [PSCustomObject]@{ Status=$Status; Process=$process; Stopwatch=[Diagnostics.Stopwatch]::StartNew() } } function Stop-CdpStatusFetchProcess { param([Parameter(Mandatory = $true)][object]$Process) if ($Process.HasExited) { return } try { if ($env:OS -eq 'Windows_NT' -and (Get-Command taskkill.exe -ErrorAction SilentlyContinue)) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null } else { $pkill = Get-Command pkill -ErrorAction SilentlyContinue if ($pkill) { & $pkill.Source -TERM -P $Process.Id 2>$null } $Process.Kill() } } catch { try { $Process.Kill() } catch {} } try { [void]$Process.WaitForExit(2000) } catch {} } function Complete-CdpStatusFetchProcess { param([object]$ActiveFetch, [int]$TimeoutSeconds, [switch]$TimedOut) $process = $ActiveFetch.Process $ActiveFetch.Stopwatch.Stop() if ($TimedOut) { Stop-CdpStatusFetchProcess -Process $process return New-CdpStatusFetchResult -Status $ActiveFetch.Status -Freshness 'fetch-failed' ` -Succeeded:$false -TimedOut:$true -Message "timeout after $TimeoutSeconds seconds" -Process $process } try { $process.WaitForExit() $success = $process.ExitCode -eq 0 $message = if ($success) { 'fetch completed' } else { "fetch failed (exit $($process.ExitCode))" } New-CdpStatusFetchResult -Status $ActiveFetch.Status ` -Freshness $(if ($success) { 'refreshed' } else { 'fetch-failed' }) ` -Succeeded:$success -TimedOut:$false -Message $message -Process $process } catch { New-CdpStatusFetchResult -Status $ActiveFetch.Status -Freshness 'fetch-failed' ` -Succeeded:$false -TimedOut:$false -Message 'fetch process failed' -Process $process } } function Invoke-CdpStatusFetchPlan { [CmdletBinding()] param([Parameter(Mandatory = $true)][object[]]$StatusList, [ValidateRange(1, 16)][int]$Jobs = 4, [ValidateRange(1, 300)][int]$TimeoutSeconds = 15) $pending = New-Object System.Collections.Queue @($StatusList | Where-Object { $_.IsGitRepo -and $_.RemoteName -and $_.RemoteName -ne '.' -and $_.RemoteRef }) | ForEach-Object { $pending.Enqueue($_) } $active = New-Object System.Collections.ArrayList $results = New-Object System.Collections.ArrayList try { while ($pending.Count -gt 0 -or $active.Count -gt 0) { while ($pending.Count -gt 0 -and $active.Count -lt $Jobs) { $status = $pending.Dequeue() try { [void]$active.Add((Start-CdpStatusFetchProcess -Status $status)) } catch { [void]$results.Add((New-CdpStatusFetchResult -Status $status -Freshness 'fetch-failed' -Succeeded:$false -TimedOut:$false -Message 'fetch process failed to start' -Process $null)) } } $completed = $false foreach ($item in @($active)) { $timedOut = $item.Stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds if (-not $item.Process.HasExited -and -not $timedOut) { continue } [void]$results.Add((Complete-CdpStatusFetchProcess -ActiveFetch $item -TimeoutSeconds $TimeoutSeconds -TimedOut:$timedOut)) [void]$active.Remove($item) $completed = $true } if (-not $completed -and $active.Count -gt 0) { Start-Sleep -Milliseconds 25 } } } finally { foreach ($item in @($active)) { Stop-CdpStatusFetchProcess -Process $item.Process } } @($results) } function Set-CdpStatusFetchResults { param([object[]]$FetchResults) $failedCount = 0 foreach ($result in @($FetchResults)) { $status = $result.Status $status.FetchAttempted = $true $status.FetchSucceeded = $result.FetchSucceeded $status.FetchTimedOut = $result.FetchTimedOut $status.FetchMessage = $result.FetchMessage $status.Freshness = $result.Freshness Update-CdpStatusSyncSnapshot -Info $status if (-not $result.FetchSucceeded) { $status.Freshness = 'fetch-failed' $status.NeedsAttention = $true $failedCount++ } } $failedCount } function Write-CdpStatusFetchAggregateError { param([int]$FailedCount, [Parameter(Mandatory = $true)][object]$Cmdlet) if ($script:CdpDeferStatusFetchError) { $script:CdpLastStatusFetchFailedCount = $FailedCount return } if ($FailedCount -le 0) { return } $message = "$FailedCount repositories failed to refresh; cached tracking data was retained." $exception = New-Object InvalidOperationException $message $record = New-Object Management.Automation.ErrorRecord $exception, 'CdpStatusFetchFailed', ` ([Management.Automation.ErrorCategory]::ConnectionError), $FailedCount $Cmdlet.WriteError($record) } function Get-CdpStatusPushPlan { param([Parameter(Mandatory = $true)][object[]]$StatusList) foreach ($project in @($StatusList | Where-Object { $_.AheadCount -gt 0 -and $_.IsGitRepo -and $_.Freshness -ne 'fetch-failed' -and $_.HeadOid -and $_.RemoteName -and $_.RemoteName -ne '.' -and $_.RemoteRef -like 'refs/heads/*' })) { [PSCustomObject]@{ Project=$project; Upstream=$project.Upstream; RemoteName=$project.RemoteName RemoteRef=$project.RemoteRef; RemoteUrl=$project.RemoteUrl; HeadOid=$project.HeadOid } } } function Invoke-CdpStatusPushPlan { param([object]$Cmdlet, [object[]]$PushPlan, [switch]$PassThru) Write-Host "`nGit push plan ($($PushPlan.Count) repositories):" -ForegroundColor Yellow foreach ($item in $PushPlan) { Write-Host " $($item.Project.Name) -> $($item.Upstream) $($item.RemoteUrl)" -ForegroundColor DarkGray } $results = @() foreach ($item in $PushPlan) { $project = $item.Project $details = [PSCustomObject]@{ Upstream=$item.Upstream; RemoteUrl=$item.RemoteUrl RemoteRef=$item.RemoteRef; HeadOid=$item.HeadOid; Freshness=$project.Freshness } if (-not $Cmdlet.ShouldProcess("$($project.Name) -> $($item.Upstream)", 'Push frozen Git snapshot')) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } $results += New-CdpActionResult -Action status-push -Target $project.Name -Status $status -Changed $false -Details $details continue } $previousErrorAction = $ErrorActionPreference try { $ErrorActionPreference = 'Continue' $null = @(& git -C $project.ResolvedPath push --porcelain $item.RemoteName ` "$($item.HeadOid):$($item.RemoteRef)" 2>&1) $pushExitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorAction } $success = $pushExitCode -eq 0 $errorText = if ($success) { '' } else { 'git-push-failed' } if ($success) { Write-Host 'done' -ForegroundColor Green } else { Write-Host "failed: $errorText" -ForegroundColor Red } $results += New-CdpActionResult -Action status-push -Target $project.Name ` -Status $(if ($success) { 'succeeded' } else { 'failed' }) -Changed:$success -Error $errorText -Details $details } if (@($results | Where-Object Status -eq 'failed').Count -gt 0) { $global:LASTEXITCODE = 1 } if ($PassThru) { $results } } |