scripts/specrew-where.ps1
|
[CmdletBinding()] param( [string]$ProjectPath = '.', [string]$FeatureId, [string]$IterationNumber, [switch]$Compact, [switch]$Ascii, [switch]$NoColor, [int]$RecentCount = 6, [int]$BarWidth = 28, [switch]$Json, [switch]$Team, [switch]$Worktrees, [switch]$Help, [string]$OutputPath, [ValidateSet('live', 'iteration-closeout', 'feature-closeout')] [string]$CaptureKind = 'live', [switch]$PreserveExistingArtifact, [AllowEmptyString()][string]$HistoricalNotice, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$CliArgs ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Test-SpecrewWhereArgumentPresent { param( [string[]]$ArgumentList, [string[]]$OptionNames ) foreach ($argument in @($ArgumentList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) { foreach ($optionName in $OptionNames) { if ($argument -eq $optionName -or $argument.StartsWith(('{0}=' -f $optionName), [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } } return $false } function Get-SpecrewWhereRawCaptureKind { param([string[]]$ArgumentList) $normalizedArguments = @($ArgumentList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) for ($index = 0; $index -lt $normalizedArguments.Count; $index++) { $argument = $normalizedArguments[$index] if ($argument -match '^--capture-kind=(.+)$') { return $Matches[1] } if ($argument -ieq '--capture-kind') { $index++ if ($index -lt $normalizedArguments.Count) { return $normalizedArguments[$index] } } } return $null } function Test-SpecrewShouldPrimeUtf8 { param( [bool]$Ascii, [bool]$NoColor, [bool]$Json, [bool]$Help, [string]$CaptureKind, [string[]]$CliArgs ) if ($Ascii -or $NoColor -or $Json -or $Help) { return $false } if (-not [string]::IsNullOrWhiteSpace($env:NO_COLOR) -or -not [string]::IsNullOrWhiteSpace($env:NO_UNICODE)) { return $false } if (Test-SpecrewWhereArgumentPresent -ArgumentList $CliArgs -OptionNames @('--ascii', '--no-color', '--json', '--help', '-h')) { return $false } $rawCaptureKind = if (-not [string]::IsNullOrWhiteSpace($CaptureKind)) { $CaptureKind } else { Get-SpecrewWhereRawCaptureKind -ArgumentList $CliArgs } if (-not [string]::IsNullOrWhiteSpace($rawCaptureKind) -and $rawCaptureKind -ine 'live') { return $false } return $true } function Enter-SpecrewUtf8ConsoleScope { $utf8Encoding = [System.Text.UTF8Encoding]::new($false) $state = [pscustomobject]@{ InputEncoding = $null OutputEncoding = $null VariableValue = $null Changed = $false } try { $state.InputEncoding = [Console]::InputEncoding } catch { $state.InputEncoding = $null } try { $state.OutputEncoding = [Console]::OutputEncoding } catch { $state.OutputEncoding = $null } try { $state.VariableValue = (Get-Variable -Name OutputEncoding -Scope Global -ErrorAction Stop).Value } catch { $state.VariableValue = $null } try { if ($null -eq $state.InputEncoding -or $state.InputEncoding.WebName -notmatch 'utf-?8') { [Console]::InputEncoding = $utf8Encoding $state.Changed = $true } if ($null -eq $state.OutputEncoding -or $state.OutputEncoding.WebName -notmatch 'utf-?8') { [Console]::OutputEncoding = $utf8Encoding $state.Changed = $true } if ($null -eq $state.VariableValue -or $state.VariableValue.WebName -notmatch 'utf-?8') { Set-Variable -Name OutputEncoding -Scope Global -Value $utf8Encoding $state.Changed = $true } } catch { # Best-effort only; rendering still falls back truthfully if UTF-8 remains unavailable. } return $state } function Exit-SpecrewUtf8ConsoleScope { param([AllowNull()][object]$State) if ($null -eq $State -or -not $State.Changed) { return } try { if ($null -ne $State.InputEncoding) { [Console]::InputEncoding = $State.InputEncoding } } catch { } try { if ($null -ne $State.OutputEncoding) { [Console]::OutputEncoding = $State.OutputEncoding } } catch { } try { if ($null -ne $State.VariableValue) { Set-Variable -Name OutputEncoding -Scope Global -Value $State.VariableValue } } catch { } } $utf8ConsoleScope = $null if (Test-SpecrewShouldPrimeUtf8 ` -Ascii $Ascii.IsPresent ` -NoColor $NoColor.IsPresent ` -Json $Json.IsPresent ` -Help $Help.IsPresent ` -CaptureKind $CaptureKind ` -CliArgs $CliArgs) { $utf8ConsoleScope = Enter-SpecrewUtf8ConsoleScope } $sharedGovernancePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'extensions\specrew-speckit\scripts\shared-governance.ps1' if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) { throw "Missing shared governance helper '$sharedGovernancePath'." } . $sharedGovernancePath $rendererPath = Join-Path $PSScriptRoot 'internal\dashboard-renderer.ps1' if (-not (Test-Path -LiteralPath $rendererPath -PathType Leaf)) { throw "Missing dashboard renderer helper '$rendererPath'." } . $rendererPath $worktreeHelperPath = Join-Path $PSScriptRoot 'internal\worktree-awareness.ps1' if (-not (Test-Path -LiteralPath $worktreeHelperPath -PathType Leaf)) { throw "Missing worktree-awareness helper '$worktreeHelperPath'." } . $worktreeHelperPath function Show-Usage { @' specrew where - show the velocity dashboard ("where am I?") Usage: specrew where [options] specrew status [options] scripts/specrew-where.ps1 [options] Options: --project-path <path> Target project root (default: current directory) --feature <id> Restrict the dashboard to one feature --iteration <NNN> Focus on one iteration when it exists --compact Render the fixed compact dashboard (24 lines max) --ASCII Force monochrome / ASCII-safe fallback rendering --no-color Force monochrome output --RecentCount <N> Show N Recent Shipped entries (default: 6) --BarWidth <N> Use N columns for rich shipped bars (default: 28) --team Reserved team path; falls back to the personal dashboard --worktrees List all git worktrees with feature and boundary state --json Emit the assembled snapshot as JSON --output-path <path> Persist the rendered dashboard or closeout snapshot --capture-kind <kind> live | iteration-closeout | feature-closeout --preserve-existing-artifact When writing an artifact, keep any existing file untouched --help Show this help message Examples: specrew where specrew status --compact specrew where --ASCII specrew where --RecentCount 4 --BarWidth 20 specrew where --no-color specrew where --team pwsh -NoProfile -File .\scripts\specrew-where.ps1 --ASCII --BarWidth 20 '@ | Write-Host } function Convert-UnixStyleArguments { param( [string]$ProjectPath, [string]$FeatureId, [string]$IterationNumber, [bool]$Compact, [bool]$Ascii, [bool]$NoColor, [int]$RecentCount, [int]$BarWidth, [bool]$Json, [bool]$Team, [bool]$Worktrees, [bool]$Help, [string]$OutputPath, [string]$CaptureKind, [bool]$PreserveExistingArtifact, [string]$HistoricalNotice, [string[]]$CliArgs ) $result = [ordered]@{ ProjectPath = $ProjectPath FeatureId = $FeatureId IterationNumber = $IterationNumber Compact = $Compact Ascii = $Ascii NoColor = $NoColor RecentCount = if ($RecentCount -gt 0) { $RecentCount } else { 6 } BarWidth = if ($BarWidth -gt 0) { $BarWidth } else { 28 } Json = $Json Team = $Team Worktrees = $Worktrees Help = $Help OutputPath = $OutputPath CaptureKind = $CaptureKind PreserveExistingArtifact = $PreserveExistingArtifact HistoricalNotice = $HistoricalNotice } $CliArgs = @($CliArgs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) for ($index = 0; $index -lt $CliArgs.Count; $index++) { $argument = $CliArgs[$index] $parsedValue = 0 switch -Regex ($argument) { '^--project-path(?:=(.+))?$' { if ($Matches[1]) { $result.ProjectPath = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--project-path requires a value.' } $result.ProjectPath = $CliArgs[$index] } } '^--feature(?:=(.+))?$' { if ($Matches[1]) { $result.FeatureId = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--feature requires a value.' } $result.FeatureId = $CliArgs[$index] } } '^--iteration(?:=(.+))?$' { if ($Matches[1]) { $result.IterationNumber = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--iteration requires a value.' } $result.IterationNumber = $CliArgs[$index] } } '^--output-path(?:=(.+))?$' { if ($Matches[1]) { $result.OutputPath = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--output-path requires a value.' } $result.OutputPath = $CliArgs[$index] } } '^--capture-kind(?:=(.+))?$' { if ($Matches[1]) { $result.CaptureKind = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--capture-kind requires a value.' } $result.CaptureKind = $CliArgs[$index] } } '^--historical-notice(?:=(.+))?$' { if ($Matches[1]) { $result.HistoricalNotice = $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--historical-notice requires a value.' } $result.HistoricalNotice = $CliArgs[$index] } } '^--compact$' { $result.Compact = $true } '^--ascii$' { $result.Ascii = $true } '^--no-color$' { $result.NoColor = $true } '^--recentcount(?:=(.+))?$' { $value = if ($Matches[1]) { $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--RecentCount requires a value.' } $CliArgs[$index] } if (-not [int]::TryParse([string]$value, [ref]$parsedValue) -or $parsedValue -le 0) { throw '--RecentCount requires a positive integer.' } $result.RecentCount = $parsedValue } '^--barwidth(?:=(.+))?$' { $value = if ($Matches[1]) { $Matches[1] } else { $index++ if ($index -ge $CliArgs.Count) { throw '--BarWidth requires a value.' } $CliArgs[$index] } if (-not [int]::TryParse([string]$value, [ref]$parsedValue) -or $parsedValue -le 0) { throw '--BarWidth requires a positive integer.' } $result.BarWidth = $parsedValue } '^--json$' { $result.Json = $true } '^--team$' { $result.Team = $true } '^--worktrees$' { $result.Worktrees = $true } '^--preserve-existing-artifact$' { $result.PreserveExistingArtifact = $true } '^(?:-h|--help)$' { $result.Help = $true } default { throw ("Unknown argument for specrew where: {0}" -f $argument) } } } return [pscustomobject]$result } function ConvertTo-SpecrewWorktreeLines { param([object[]]$Worktrees) $lines = New-Object System.Collections.Generic.List[string] $lines.Add('Specrew worktrees') | Out-Null $lines.Add('-----------------') | Out-Null foreach ($worktree in @($Worktrees)) { $featureLabel = if (-not [string]::IsNullOrWhiteSpace([string]$worktree.feature_number)) { [string]$worktree.feature_number } elseif (-not [string]::IsNullOrWhiteSpace([string]$worktree.feature_ref)) { [string]$worktree.feature_ref } else { '(none)' } $boundaryLabel = if (-not [string]::IsNullOrWhiteSpace([string]$worktree.boundary_type)) { [string]$worktree.boundary_type } else { '(none)' } $activityLabel = if (-not [string]::IsNullOrWhiteSpace([string]$worktree.last_activity)) { [string]$worktree.last_activity } else { '(none)' } $pathLine = [string]$worktree.path if (-not $worktree.exists -and -not [string]::IsNullOrWhiteSpace([string]$worktree.note)) { $pathLine = '{0} {1}' -f $pathLine, [string]$worktree.note } elseif (-not [string]::IsNullOrWhiteSpace([string]$worktree.note)) { $pathLine = '{0} {1}' -f $pathLine, [string]$worktree.note } $marker = if ($worktree.is_current) { '*' } else { '-' } $lines.Add(('{0} {1}' -f $marker, $pathLine)) | Out-Null $lines.Add((' Feature: {0}' -f $featureLabel)) | Out-Null $lines.Add((' Boundary: {0}' -f $boundaryLabel)) | Out-Null $lines.Add((' Last activity: {0}' -f $activityLabel)) | Out-Null } return $lines.ToArray() } function ConvertTo-SpecrewWorktreePayload { param([object[]]$Worktrees) return [pscustomobject]@{ worktrees = @($Worktrees) lines = @(ConvertTo-SpecrewWorktreeLines -Worktrees $Worktrees) } } try { $parsed = Convert-UnixStyleArguments ` -ProjectPath $ProjectPath ` -FeatureId $FeatureId ` -IterationNumber $IterationNumber ` -Compact $Compact.IsPresent ` -Ascii $Ascii.IsPresent ` -NoColor $NoColor.IsPresent ` -RecentCount $RecentCount ` -BarWidth $BarWidth ` -Json $Json.IsPresent ` -Team $Team.IsPresent ` -Worktrees $Worktrees.IsPresent ` -Help $Help.IsPresent ` -OutputPath $OutputPath ` -CaptureKind $CaptureKind ` -PreserveExistingArtifact $PreserveExistingArtifact.IsPresent ` -HistoricalNotice $HistoricalNotice ` -CliArgs $CliArgs if ($parsed.Help) { Show-Usage exit 0 } if ($parsed.Worktrees) { $worktreeState = @(Get-WorktreeState -ProjectRoot $parsed.ProjectPath) $lines = ConvertTo-SpecrewWorktreeLines -Worktrees $worktreeState if ($parsed.Json) { ConvertTo-SpecrewWorktreePayload -Worktrees $worktreeState | ConvertTo-Json -Depth 6 exit 0 } Write-SpecrewDashboardLines -Lines $lines -ColorMode $(if ($parsed.NoColor -or $parsed.Ascii) { 'plain' } else { 'plain' }) exit 0 } $snapshot = Get-SpecrewDashboardSnapshot ` -ProjectRoot $parsed.ProjectPath ` -FeatureId $parsed.FeatureId ` -IterationNumber $parsed.IterationNumber ` -Compact:$parsed.Compact ` -Ascii:$parsed.Ascii ` -NoColor:$parsed.NoColor ` -RecentCount $parsed.RecentCount ` -BarWidth $parsed.BarWidth ` -CaptureKind $parsed.CaptureKind ` -Team:$parsed.Team $lines = if ($parsed.Compact) { ConvertTo-SpecrewCompactDashboardLines -Snapshot $snapshot } else { ConvertTo-SpecrewDashboardLines -Snapshot $snapshot } if (-not [string]::IsNullOrWhiteSpace($parsed.OutputPath)) { $resolvedOutputPath = Resolve-ProjectPath -Path $parsed.OutputPath if ($parsed.PreserveExistingArtifact -and (Test-Path -LiteralPath $resolvedOutputPath -PathType Leaf)) { Write-Host "Preserved existing dashboard artifact at $resolvedOutputPath" } else { $artifactContent = ConvertTo-SpecrewDashboardArtifactContent -Snapshot $snapshot -Lines $lines -CaptureKind $parsed.CaptureKind -HistoricalNotice $parsed.HistoricalNotice Write-Utf8FileAtomic -Path $resolvedOutputPath -Content $artifactContent Write-Host "Wrote dashboard artifact to $resolvedOutputPath" } } if ($parsed.Json) { $payload = [pscustomobject]@{ snapshot = $snapshot lines = $lines } $payload | ConvertTo-Json -Depth 8 exit 0 } Write-SpecrewDashboardLines -Lines $lines -ColorMode $snapshot.color_mode exit 0 } finally { Exit-SpecrewUtf8ConsoleScope -State $utf8ConsoleScope } |