src/PowerShell/Status.ps1
|
# cdp PowerShell domain: Status.ps1 # Loaded by src/cdp.psm1; do not dot-source peer files. function New-CdpGitProjectInfo { param( [Parameter(Mandatory = $true)] [object]$Project ) $resolution = Resolve-CdpProjectPath -Project $Project [PSCustomObject]@{ Name = [string]$Project.name RootPath = $resolution.RawPath ResolvedPath = $resolution.ResolvedPath PathProfile = $resolution.Profile PathSource = $resolution.Source IsExplicitPath = $resolution.IsExplicit PathResolutionError = $resolution.ErrorCode PathExists = $false IsGitRepo = $false Branch = "" Remote = "" Upstream = "" DirtyCount = 0 UntrackedCount = 0 AheadCount = 0 BehindCount = 0 LastCommitRelative = "" StatusLabel = "" NeedsAttention = $false } } function ConvertFrom-CdpGitStatusPorcelainV2 { param( [Parameter(Mandatory = $true)][string[]]$Lines, [Parameter(Mandatory = $true)][object]$Info ) $oid = '' $head = '' foreach ($line in $Lines) { if ($line -match '^# branch\.oid (.+)$') { $oid = $matches[1]; continue } if ($line -match '^# branch\.head (.+)$') { $head = $matches[1]; continue } if ($line -match '^# branch\.upstream (.+)$') { $Info.Upstream = $matches[1] $separator = $Info.Upstream.IndexOf('/') if ($separator -gt 0) { $Info.Remote = $Info.Upstream.Substring(0, $separator) } continue } if ($line -match '^# branch\.ab \+(\d+) -(\d+)$') { $Info.AheadCount = [int]$matches[1] $Info.BehindCount = [int]$matches[2] continue } if ($line.StartsWith('? ')) { $Info.UntrackedCount++; continue } if ($line -match '^(1|2|u) ') { $Info.DirtyCount++ } } if (-not [string]::IsNullOrWhiteSpace($head) -and $head -ne '(detached)') { $Info.Branch = $head } elseif ($oid -notin @('', '(initial)')) { $Info.Branch = $oid.Substring(0, [Math]::Min(7, $oid.Length)) } $oid } function Set-CdpGitProjectStatusLabel { param([Parameter(Mandatory = $true)][object]$Info) if ($Info.DirtyCount -gt 0 -and $Info.UntrackedCount -gt 0) { $Info.StatusLabel = "$($Info.DirtyCount) dirty + $($Info.UntrackedCount) untracked" } elseif ($Info.DirtyCount -gt 0) { $Info.StatusLabel = "$($Info.DirtyCount) dirty" } elseif ($Info.UntrackedCount -gt 0) { $Info.StatusLabel = "$($Info.UntrackedCount) untracked" } else { $Info.StatusLabel = 'clean' } $Info.NeedsAttention = $Info.DirtyCount -gt 0 -or $Info.UntrackedCount -gt 0 -or $Info.BehindCount -gt 0 } function Get-CdpGitProjectInfo { param( [Parameter(Mandatory = $true)] [object]$Project ) $info = New-CdpGitProjectInfo -Project $Project $rootPath = [string]$info.ResolvedPath if ($info.PathResolutionError) { $info.StatusLabel = 'path profile invalid' $info.NeedsAttention = $true return $info } if (-not (Test-Path -LiteralPath $rootPath)) { $info.StatusLabel = "path missing" $info.NeedsAttention = $true return $info } $info.PathExists = $true try { $porcelain = @(& git -C $rootPath status --porcelain=v2 --branch --untracked-files=all 2>$null) $statusExitCode = $LASTEXITCODE } catch { $statusExitCode = 1 $porcelain = @() } if ($statusExitCode -ne 0) { $info.StatusLabel = "not a git repo" return $info } $info.IsGitRepo = $true $oid = ConvertFrom-CdpGitStatusPorcelainV2 -Lines $porcelain -Info $info if ($oid -notin @('', '(initial)')) { try { $info.LastCommitRelative = (& git -C $rootPath log -1 --format="%cr" 2>$null) } catch {} } Set-CdpGitProjectStatusLabel -Info $info return $info } function Show-CdpProjectStatus { <# .SYNOPSIS Show Git status of all configured projects. .DESCRIPTION Displays a dashboard view of all enabled projects showing current branch, working tree status, ahead/behind counts, and last commit time. Quickly answers: which repos have uncommitted changes? Which are behind remote? .PARAMETER ConfigPath Optional custom path to projects.json file. .PARAMETER DirtyOnly Only show projects that need attention (dirty, untracked, or behind remote). .PARAMETER TagFilter Only show projects matching a tag (e.g. '@work'). .PARAMETER PassThru Returns project status objects for scripting. .PARAMETER Json Writes one schema-versioned JSON document for automation. .PARAMETER NoColor Writes the human-readable table without color styling. .EXAMPLE cdp status # Shows Git status of all projects .EXAMPLE cdp status --dirty # Only shows projects with uncommitted changes .EXAMPLE cdp status @work # Shows status of projects tagged 'work' .EXAMPLE cdp status --json # Emits status schema version 1 and automation exit codes #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $false)] [string]$ConfigPath, [Parameter(Mandatory = $false)] [Alias('d')] [switch]$DirtyOnly, [Parameter(Mandatory = $false)] [string]$TagFilter, [Parameter(Mandatory = $false)] [switch]$PassThru, [Parameter(Mandatory = $false)] [switch]$Fix, [Parameter(Mandatory = $false)] [switch]$Push, [Parameter(Mandatory = $false)] [switch]$Refresh, [Parameter(Mandatory = $false)] [int]$ThrottleLimit = 0, [Parameter(Mandatory = $false)] [switch]$Json, [Parameter(Mandatory = $false)] [switch]$NoColor ) if ($Json -and $NoColor) { Write-CdpStatusFatal -Message 'The -Json and -NoColor options cannot be used together.' -Json; return } if ($Json -and ($Fix -or $Push)) { Write-CdpStatusFatal -Message 'The -Json option is only valid for read-only status.' -Json; return } if ($NoColor -and ($Fix -or $Push)) { Write-CdpStatusFatal -Message 'The -NoColor option is only valid for read-only status.'; return } if ($Json -and $PassThru) { Write-CdpStatusFatal -Message 'The -Json and -PassThru options cannot be used together.' -Json; return } if ($NoColor -and $PassThru) { Write-CdpStatusFatal -Message 'The -NoColor and -PassThru options cannot be used together.'; return } try { [void](Get-CdpCurrentPathProfile) } catch { Write-CdpStatusFatal -Message $_.Exception.Message -Json:$Json return } if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Get-DefaultConfigPath } if (-not (Test-Path $ConfigPath)) { Write-CdpStatusFatal -Message "Configuration file not found at: $ConfigPath" -Json:$Json return } $document = $null try { if ($Fix) { $document = Read-CdpJsonDocument -LiteralPath $ConfigPath $enabledProjects = @(@($document.Value) | Where-Object { $_.enabled }) } else { $configData = Get-CdpProjectConfig -ConfigPath $ConfigPath $enabledProjects = @($configData.EnabledProjects) } } catch { Write-CdpStatusFatal -Message 'Failed to read configuration.' -Json:$Json return } if (-not [string]::IsNullOrWhiteSpace($TagFilter)) { $tagQuery = $TagFilter if ($tagQuery.StartsWith('@')) { $tagQuery = $tagQuery.Substring(1) } $comparison = [StringComparison]::OrdinalIgnoreCase $enabledProjects = @($enabledProjects | Where-Object { @(Get-CdpProjectStringList -Project $_ -PropertyName 'tags') | Where-Object { [string]::Equals($_, $tagQuery, $comparison) } }) } if ($enabledProjects.Count -eq 0) { if ($Json) { $empty = New-CdpStatusDocument -AllStatus @() -VisibleStatus @() -DurationMs 0 -DirtyOnly:$DirtyOnly -TagFilter $TagFilter -Refresh:$Refresh Write-CdpStatusJson -Document $empty } elseif ($NoColor) { Write-Host 'No projects to check.' } else { Write-Host "No projects to check." -ForegroundColor Yellow } return } $total = $enabledProjects.Count $forceRefresh = $Refresh -or $Fix -or $Push $workerCount = Resolve-CdpStatusThrottleLimit -Value $ThrottleLimit $scanWatch = [Diagnostics.Stopwatch]::StartNew() if (-not $Json) { if ($NoColor) { Write-Host "`r Scanning $total projects ($workerCount workers)... " -NoNewline } else { Write-Host "`r Scanning $total projects ($workerCount workers)... " -ForegroundColor DarkGray -NoNewline } } $statusList = @(Get-CdpGitProjectInfoBatch ` -Projects $enabledProjects ` -ThrottleLimit $workerCount ` -Refresh:$forceRefresh ` -CollectorScript { param($Project) Get-CdpGitProjectInfo -Project $Project }) $scanWatch.Stop() if (-not $Json) { Write-Host "`r `r" -NoNewline } if ($Fix) { $invalidPathProjects = @($statusList | Where-Object { $_.PathResolutionError }) $explicitMissingProjects = @($statusList | Where-Object { -not $_.PathResolutionError -and -not $_.PathExists -and $_.IsExplicitPath }) $missingProjects = @($statusList | Where-Object { -not $_.PathResolutionError -and -not $_.PathExists -and -not $_.IsExplicitPath }) if ($invalidPathProjects.Count -gt 0) { Write-Host "`nKeeping $($invalidPathProjects.Count) projects with invalid path profiles:" -ForegroundColor Yellow foreach ($project in $invalidPathProjects) { Write-Host " $($project.Name) -> $($project.PathSource)" -ForegroundColor DarkGray } } if ($explicitMissingProjects.Count -gt 0) { Write-Host "`nKeeping $($explicitMissingProjects.Count) projects with unavailable explicit profile paths:" -ForegroundColor Yellow foreach ($project in $explicitMissingProjects) { Write-Host " $($project.Name) [$($project.PathProfile)] -> $($project.ResolvedPath)" -ForegroundColor DarkGray } } if ($missingProjects.Count -eq 0) { Write-Host "`nNo path-missing projects to remove." -ForegroundColor Green if ($PassThru) { return @() } return } Write-Host "`nRemoving $($missingProjects.Count) path-missing projects:" -ForegroundColor Yellow foreach ($proj in $missingProjects) { Write-Host " x $($proj.Name)" -ForegroundColor DarkGray -NoNewline Write-Host " $($proj.RootPath)" -ForegroundColor DarkGray } $resolvedConfig = if (-not [string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath } else { Get-DefaultConfigPath } if (-not $PSCmdlet.ShouldProcess($resolvedConfig, "Remove $($missingProjects.Count) missing project entries")) { if ($PassThru) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } return @($missingProjects | ForEach-Object { New-CdpActionResult -Action 'status-fix' -Target ([string]$_.Name) -Status $status -Changed $false -Details $_ }) } return } $allProjects = $document.Value $missingIdentities = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($project in $missingProjects) { $identity = "$($project.Name)`0$(Get-CdpComparablePath -Path $project.RootPath)" [void]$missingIdentities.Add($identity) } $cleaned = @(@($allProjects) | Where-Object { $identity = "$($_.name)`0$(Get-CdpComparablePath -Path ([string]$_.rootPath))" $_.enabled -ne $true -or -not $missingIdentities.Contains($identity) }) try { [void](Write-CdpJsonFile -LiteralPath $resolvedConfig -Value @($cleaned) -ExpectedFingerprint $document.Fingerprint) } catch { $errorMessage = $_.Exception.Message Write-Host "`nFailed to remove missing projects: $errorMessage" -ForegroundColor Red if ($PassThru) { return @($missingProjects | ForEach-Object { New-CdpActionResult -Action 'status-fix' -Target ([string]$_.Name) -Status 'failed' -Changed $false -Error $errorMessage -Details $_ }) } throw } Write-Host "`nRemoved $($missingProjects.Count) projects. $($cleaned.Count) projects remain." -ForegroundColor Green if ($PassThru) { return @($missingProjects | ForEach-Object { New-CdpActionResult -Action 'status-fix' -Target ([string]$_.Name) -Status 'succeeded' -Changed $true -Details $_ }) } return } if ($Push) { $aheadProjects = @($statusList | Where-Object { $_.AheadCount -gt 0 -and $_.IsGitRepo }) if ($aheadProjects.Count -eq 0) { Write-Host "`nNo repos ahead of remote." -ForegroundColor Green if ($PassThru) { return @() } return } Write-Host "`nPushing $($aheadProjects.Count) repos ahead of remote:" -ForegroundColor Yellow $pushResults = @() $pushFailed = $false foreach ($proj in $aheadProjects) { $upstreamLabel = if ($proj.Upstream) { "remote=$($proj.Remote), upstream=$($proj.Upstream)" } else { 'configured upstream' } Write-Host " $($proj.Name) (^$($proj.AheadCount), $upstreamLabel)" -ForegroundColor Cyan if (-not $PSCmdlet.ShouldProcess("$($proj.Name) [$upstreamLabel]", 'Push commits to configured upstream')) { $status = if ($WhatIfPreference) { 'preview' } else { 'canceled' } $pushResults += New-CdpActionResult -Action 'status-push' -Target ([string]$proj.Name) -Status $status -Changed $false -Details $proj continue } Write-Host " running... " -ForegroundColor DarkGray -NoNewline try { $pushOutput = @(& git -C $proj.ResolvedPath push --porcelain 2>&1) if ($LASTEXITCODE -eq 0) { Write-Host "done" -ForegroundColor Green $pushResults += New-CdpActionResult -Action 'status-push' -Target ([string]$proj.Name) -Status 'succeeded' -Changed $true -Details $proj } else { $failure = @($pushOutput | Select-Object -Last 1) -join '' Write-Host "failed: $failure" -ForegroundColor Red $pushFailed = $true $pushResults += New-CdpActionResult -Action 'status-push' -Target ([string]$proj.Name) -Status 'failed' -Changed $false -Error $failure -Details $proj } } catch { Write-Host "failed: $($_.Exception.Message)" -ForegroundColor Red $pushFailed = $true $pushResults += New-CdpActionResult -Action 'status-push' -Target ([string]$proj.Name) -Status 'failed' -Changed $false -Error $_.Exception.Message -Details $proj } } if ($pushFailed -and -not $PassThru) { $global:LASTEXITCODE = 1 } if ($PassThru) { return $pushResults } return } $visibleStatus = if ($DirtyOnly) { @($statusList | Where-Object { $_.NeedsAttention }) } else { @($statusList) } if ($Json) { try { $document = New-CdpStatusDocument -AllStatus $statusList -VisibleStatus $visibleStatus ` -DurationMs ([int][Math]::Round($scanWatch.Elapsed.TotalMilliseconds)) ` -DirtyOnly:$DirtyOnly -TagFilter $TagFilter -Refresh:$Refresh Write-CdpStatusJson -Document $document } catch { Write-CdpStatusFatal -Message 'Failed to build status JSON.' -Json } return } if ($NoColor) { Write-CdpPlainStatusTable -StatusList $visibleStatus -DirtyOnly:$DirtyOnly -TagFilter $TagFilter return } if ($PassThru) { if ($DirtyOnly) { return @($statusList | Where-Object { $_.NeedsAttention }) } return $statusList } if ($DirtyOnly) { $statusList = @($statusList | Where-Object { $_.NeedsAttention }) } $nameWidth = 14 foreach ($item in $statusList) { $nameWidth = [Math]::Max($nameWidth, (Get-CdpDisplayWidth $item.Name)) } $nameWidth = [Math]::Min($nameWidth, 24) $branchWidth = 12 foreach ($item in $statusList) { $bw = Get-CdpDisplayWidth $item.Branch if ($bw -gt $branchWidth) { $branchWidth = [Math]::Min($bw, 20) } } $filterLabel = if ($DirtyOnly) { " (dirty only)" } elseif (-not [string]::IsNullOrWhiteSpace($TagFilter)) { " ($TagFilter)" } else { "" } Write-Host "`ncdp project status " -ForegroundColor Cyan -NoNewline Write-Host "($($statusList.Count) projects$filterLabel)" -ForegroundColor DarkGray Write-Host ("-" * 110) -ForegroundColor DarkGray Write-Host (" {0,-4} " -f "#") -ForegroundColor DarkGray -NoNewline Write-Host ("{0,-$nameWidth} " -f "Project") -ForegroundColor Cyan -NoNewline Write-Host ("{0,-$branchWidth} " -f "Branch") -ForegroundColor DarkGray -NoNewline Write-Host ("{0,-24} " -f "Status") -ForegroundColor DarkGray -NoNewline Write-Host ("{0,-10} " -f "Sync") -ForegroundColor DarkGray -NoNewline Write-Host "Last Commit" -ForegroundColor DarkGray Write-Host ("-" * 110) -ForegroundColor DarkGray $index = 1 foreach ($item in $statusList) { $number = "{0:00}" -f $index $displayName = Limit-CdpText -Text $item.Name -MaxLength $nameWidth $displayBranch = Limit-CdpText -Text $item.Branch -MaxLength $branchWidth Write-Host (" {0,-4} " -f $number) -ForegroundColor DarkGray -NoNewline Write-Host "$(Pad-CdpText $displayName $nameWidth) " -ForegroundColor Green -NoNewline if (-not $item.IsGitRepo) { Write-Host "$(Pad-CdpText '-' $branchWidth) " -ForegroundColor DarkGray -NoNewline $labelColor = if ($item.PathExists) { "DarkGray" } else { "Red" } Write-Host $item.StatusLabel -ForegroundColor $labelColor $index++ continue } Write-Host "$(Pad-CdpText $displayBranch $branchWidth) " -ForegroundColor DarkCyan -NoNewline $statusIcon = if ($item.DirtyCount -gt 0) { "x" } elseif ($item.UntrackedCount -gt 0) { "!" } else { "+" } $statusColor = if ($item.DirtyCount -gt 0) { "Red" } elseif ($item.UntrackedCount -gt 0) { "Yellow" } else { "Green" } $statusText = "$statusIcon $($item.StatusLabel)" Write-Host ("{0,-24} " -f $statusText) -ForegroundColor $statusColor -NoNewline $syncParts = @() if ($item.AheadCount -gt 0) { $syncParts += "^$($item.AheadCount)" } if ($item.BehindCount -gt 0) { $syncParts += "v$($item.BehindCount)" } $syncText = if ($syncParts.Count -gt 0) { $syncParts -join " " } else { "" } $syncColor = if ($item.BehindCount -gt 0) { "Yellow" } elseif ($item.AheadCount -gt 0) { "Cyan" } else { "DarkGray" } Write-Host ("{0,-10} " -f $syncText) -ForegroundColor $syncColor -NoNewline Write-Host $item.LastCommitRelative -ForegroundColor DarkGray $index++ } Write-Host ("-" * 110) -ForegroundColor DarkGray $attentionCount = @($statusList | Where-Object { $_.NeedsAttention -and $_.IsGitRepo }).Count $missingCount = @($statusList | Where-Object { -not $_.PathExists }).Count $summaryParts = @() if ($attentionCount -gt 0) { $summaryParts += "$attentionCount repos need attention" } if ($missingCount -gt 0) { $summaryParts += "$missingCount path missing" } if ($summaryParts.Count -gt 0) { Write-Host ($summaryParts -join " | ") -ForegroundColor Yellow } else { Write-Host "All projects clean." -ForegroundColor Green } if ($summaryParts.Count -gt 0) { Write-Host "" if ($missingCount -gt 0) { Write-Host " Tip: cdp status --fix Remove $missingCount path-missing projects" -ForegroundColor DarkGray } $aheadCount = @($statusList | Where-Object { $_.AheadCount -gt 0 -and $_.IsGitRepo }).Count if ($aheadCount -gt 0) { Write-Host " Tip: cdp status --push Push $aheadCount repos ahead of remote" -ForegroundColor DarkGray } } } function Invoke-CdpStatusInvocation { param([object]$Invocation) $parameters = @{ ConfigPath = $Invocation.ConfigPath DirtyOnly = $Invocation.DirtyOnly TagFilter = $Invocation.TagFilter Fix = $Invocation.Fix Push = $Invocation.Push Refresh = $Invocation.Refresh ThrottleLimit = $Invocation.ThrottleLimit Json = $Invocation.Json NoColor = $Invocation.NoColor } if ($Invocation.DryRun) { $parameters.WhatIf = $true } if ($Invocation.Yes) { $parameters.Confirm = $false } Show-CdpProjectStatus @parameters } |