gitbox.ps1

# Flag-stack orchestrator. Routes flag sequences to scripts in canonical order.
# Usage: gitbox <flags|workflow> [arg ...] [-AllowWip]
# Flags: b=branch-create r=rename s=sync c=commit v=revert u=push o=open-pr x=pr-checks m=merge-rotate g=branch-base z=release
# H=health Q=status L=log D=diff P=pr-view
# S=matrix-scan B=backlog C=capabilities W=workflow-registry O=optimize X=run-logs
# -AllowWip: skip the wip-branch rename prompt and commit on the wip branch as-is

param(
    [Parameter(Position=0)]
    [string]$Spec = '',
    [Parameter(ValueFromPipeline)]
    [string]$PipelineArg,
    [Parameter(Position=1, ValueFromRemainingArguments)]
    [string[]]$Rest,
    [switch]$AllowWip
)

. (Join-Path $PSScriptRoot 'g-error-vectors.ps1')
. (Join-Path $PSScriptRoot 'g-spinner.ps1')

$_e = [char]27
$_d  = "${_e}[2m";  $_b  = "${_e}[1m"
$_cy = "${_e}[36m"; $_gn = "${_e}[32m"
$_yw = "${_e}[33m"; $_rd = "${_e}[31m"
$_rs = "${_e}[0m"
if ([Console]::IsOutputRedirected) { $_d=''; $_b=''; $_cy=''; $_gn=''; $_yw=''; $_rd=''; $_rs='' }

function Show-GitboxHelp {
    Write-Host ""
    Write-Host " ${_b}${_cy}gitbox${_rs} git workflow automation"
    Write-Host ""
    Write-Host " ${_b}${_yw}USAGE${_rs}"
    Write-Host " gitbox ${_cy}<flags|workflow>${_rs} [args] [-AllowWip]"
    Write-Host " gb ${_cy}<flags|workflow>${_rs} [args] [-AllowWip]"
    Write-Host ""
    Write-Host " ${_b}${_yw}FLAGS${_rs} ${_d}mutating, run in pipeline order${_rs}"
    @(
        'b|branch-create|<name>|Create a feature branch from base'
        'r|branch-rename|<name>|Rename current branch'
        's|branch-sync||Fetch and rebase onto base'
        'c|commit-push|[message]|Stage all, commit, and push'
        'v|revert|[ref]|Revert a commit (default: HEAD)'
        'u|push||Push unpushed commits'
        'o|open-pr|[title]|Open a PR against the base branch'
        'x|pr-checks||Check CI status'
        'm|merge-rotate|[name]|Merge PR, delete branch, create next'
        'g|branch-base||Checkout base branch and pull'
        'k|branch-checkout|<name>|Checkout any named branch with stash-and-pop'
        'n|unstack||Merge the full stacked PR chain bottom-to-top'
        'z|release|[version]|Tag and push; promotes develop to main first if applicable'
    ) | ForEach-Object {
        $p = $_ -split '\|', 4
        Write-Host (" ${_cy}{0}${_rs} {1,-14} {2,-10} {3}" -f $p[0], $p[1], $p[2], $p[3])
    }
    Write-Host ""
    Write-Host " ${_d} diagnostic${_rs}"
    @(
        'H|health|Unified health report'
        'Q|status|One-line repo status'
        'L|log|Commits ahead of base'
        'D|diff|Changed files and line counts'
        'P|pr-view|PR detail: title, state, reviews, checks'
        'S|matrix-scan|State hash and recommended next action'
        'B|backlog|360-combination gap sweep'
        'C|capabilities|Script coverage scores'
        'W|workflow-registry|Named workflows with capabilities'
        'O|optimization|Capability density per script'
        'X|run-logs|Most recent CI run logs'
        'T|stack|Stack topology: branch chain, PR numbers, CI status'
    ) | ForEach-Object {
        $p = $_ -split '\|', 3
        Write-Host (" ${_cy}{0}${_rs} {1,-20} {2}" -f $p[0], $p[1], $p[2])
    }
    Write-Host ""
    Write-Host " ${_b}${_yw}WORKFLOWS${_rs}"
    @(
        'start|b|Beginning a new ticket from the base branch'
        'rename|r|Promoting a wip branch before opening a PR'
        'sync|s|Branch is behind base'
        'commit|c|Saving incremental progress on an open PR'
        'push|u|Pushing commits made outside gitbox'
        'pr|o|Opening a PR on an already-pushed branch'
        'checks|x|Inspecting CI status'
        'merge|m|Merging an approved PR'
        'revert|v|Undoing a commit'
        'promote|rcuo|Promote a wip branch to a feature branch with a PR'
        'base|g|Return to base branch after merge or before release'
        'checkout|k|Switch to any named branch with stash-and-pop'
        'unstack|n|Merge the full stacked PR chain bottom-to-top'
        'stack|T|Show current stack topology'
        'submit|cuo|Commit, push, and open PR — stop before merge'
        'land|cxm|Final commit on a branch with an open PR'
        'ship|xm|Merging a clean, already-committed branch'
        'full|cuoxm|One-shot from commit through merge'
        'release|z|Promoting develop to main with a version tag'
    ) | ForEach-Object {
        $p = $_ -split '\|', 3
        Write-Host (" ${_cy}{0,-8}${_rs} ${_b}{1,-6}${_rs} {2}" -f $p[0], $p[1], $p[2])
    }
    Write-Host ""
    Write-Host " ${_b}${_yw}EXAMPLES${_rs}"
    @(
        'gitbox b "feat/my-feature"|create a feature branch'
        'gitbox c "fix the thing"|commit all changes'
        'gitbox co "fix the thing" "Fix the thing"|commit and open PR'
        'gitbox land "fix the thing"|commit, check CI, merge'
        'gitbox ship|check CI and merge'
    ) | ForEach-Object {
        $p = $_ -split '\|', 2
        Write-Host (" ${_gn}{0,-46}${_rs} ${_d}{1}${_rs}" -f $p[0], $p[1])
    }
    Write-Host ""
}

if (-not $Spec -or $Spec -in @('--help', '-h', '-?', 'help')) { Show-GitboxHelp; exit 0 }
if ($Spec -eq 'init') { & (Join-Path $PSScriptRoot 'g-init.ps1'); exit $LASTEXITCODE }
if ($Spec -in @('--version', 'version')) {
    $manifest = Import-PowerShellDataFile (Join-Path $PSScriptRoot 'gitbox.psd1')
    Write-Host "gitbox $($manifest.ModuleVersion)"
    exit 0
}

# Case-sensitive: lowercase=mutating, uppercase=diagnostic; 's' and 'S' are distinct keys
$FlagMap = [System.Collections.Hashtable]::new([System.StringComparer]::Ordinal)
$FlagMap['b'] = @{ Script = 'g-branch-create.ps1';  NeedsArg = $true;  Force = $true; Switches = @('Stack') }
$FlagMap['r'] = @{ Script = 'g-branch-rename.ps1';  NeedsArg = $true  }
$FlagMap['s'] = @{ Script = 'g-branch-sync.ps1';    NeedsArg = $false }
$FlagMap['c'] = @{ Script = 'g-commit-push.ps1';    NeedsArg = 'optional'; Switches = @('Amend') }
$FlagMap['v'] = @{ Script = 'g-revert.ps1';         NeedsArg = 'optional' }
$FlagMap['u'] = @{ Script = 'g-push.ps1';           NeedsArg = $false }
$FlagMap['o'] = @{ Script = 'g-open-pr.ps1';        NeedsArg = 'optional'; Switches = @('Draft') }
$FlagMap['x'] = @{ Script = 'g-pr-checks.ps1';      NeedsArg = $false }
$FlagMap['m'] = @{ Script = 'g-merge-rotate.ps1';   NeedsArg = 'optional'; Switches = @('Squash','Rebase') }
$FlagMap['g'] = @{ Script = 'g-branch-base.ps1';    NeedsArg = $false; Switches = @('NoStashPop') }
$FlagMap['k'] = @{ Script = 'g-branch-checkout.ps1'; NeedsArg = $true }
$FlagMap['n'] = @{ Script = 'g-unstack.ps1';         NeedsArg = $false; Switches = @('Force') }
$FlagMap['z'] = @{ Script = 'g-release.ps1';        NeedsArg = 'optional'; Switches = @('View') }
$FlagMap['Q'] = @{ Script = 'g-status.ps1';         NeedsArg = $false }
$FlagMap['S'] = @{ Script = 'g-matrix-scan.ps1';    NeedsArg = $false }
$FlagMap['B'] = @{ Script = 'g-backlog.ps1';        NeedsArg = $false }
$FlagMap['C'] = @{ Script = 'g-capabilities.ps1';   NeedsArg = $false }
$FlagMap['W'] = @{ Script = $null;                  NeedsArg = $false }
$FlagMap['O'] = @{ Script = $null;                  NeedsArg = $false }
$FlagMap['H'] = @{ Script = 'g-health.ps1';         NeedsArg = $false }
$FlagMap['L'] = @{ Script = 'g-log.ps1';            NeedsArg = $false }
$FlagMap['D'] = @{ Script = 'g-diff.ps1';           NeedsArg = $false }
$FlagMap['P'] = @{ Script = 'g-pr-view.ps1';        NeedsArg = $false }
$FlagMap['X'] = @{ Script = 'g-run-logs.ps1';       NeedsArg = $false }
$FlagMap['T'] = @{ Script = 'g-stack.ps1';          NeedsArg = $false }

$CanonicalOrder = [string[]]@('b','r','s','c','v','u','o','x','m','g','k','n','z','H','Q','L','D','P','S','B','C','W','O','X','T')

# Resolve workflow name, workflow-prefix+flags compound (e.g. shipX), or raw flag string
$flagStr = if ($WorkflowRegistry.Contains($Spec)) {
    $WorkflowRegistry[$Spec]
} else {
    $matched = $null
    foreach ($wf in ($WorkflowRegistry.Keys | Sort-Object { $_.Length } -Descending)) {
        if ($Spec.StartsWith($wf) -and $Spec.Length -gt $wf.Length) {
            $matched = $WorkflowRegistry[$wf] + $Spec.Substring($wf.Length)
            break
        }
    }
    if ($matched) { $matched } else { $Spec.TrimStart('-') }
}

# Validate all flag characters
foreach ($ch in $flagStr.ToCharArray()) {
    if (-not $FlagMap.Contains([string]$ch)) {
        $flip = if ([char]::IsUpper($ch)) { [string][char]::ToLower($ch) } else { [string][char]::ToUpper($ch) }
        $hint = if ($FlagMap.Contains($flip)) { " (did you mean '$flip'?)" } else { '' }
        Write-Host "gitbox: unknown flag '$ch'${hint} -- valid flags: $($FlagMap.Keys -join '')"
        exit 1
    }
}

# Build ordered step list
$steps = [System.Collections.Generic.List[psobject]]::new()
foreach ($f in $CanonicalOrder) {
    if ($flagStr.Contains($f)) {
        $steps.Add([pscustomobject]@{ Flag = $f; Info = $FlagMap[$f] })
    }
}

# Verify arg count before executing anything; 'optional' flags are not counted as required
$argSteps = @($steps | Where-Object { $_.Info.NeedsArg -eq $true })
$posArgs  = if ($Rest) { @($Rest | Where-Object { "$_" -notmatch '^-' }) } else { @() }
$argCount  = $posArgs.Count + ($PipelineArg ? 1 : 0)
if ($argCount -lt $argSteps.Count) {
    $missing = $argSteps[$argCount]
    $name    = $missing.Info.Script -replace '\.ps1$','' -replace '^g-',''
    Write-Host ("gitbox: flag '$($missing.Flag)' ($name) needs an argument -- " +
                "$($argSteps.Count) required, $argCount provided")
    exit 1
}

# --- Execute mutating steps ---
$argQueue    = [System.Collections.Generic.Queue[string]]::new()
$restSwitches = @{}
if ($PipelineArg) { $argQueue.Enqueue($PipelineArg) }
if ($Rest) {
    foreach ($a in $Rest) {
        if ("$a" -match '^-([A-Za-z]\w*)$') { $restSwitches[$Matches[1]] = $true }
        else { $argQueue.Enqueue($a) }
    }
}

$maxConsumable = @($steps | Where-Object { $_.Info.NeedsArg -in @($true, 'optional') }).Count
if ($argQueue.Count -gt $maxConsumable) {
    $tempArr   = $argQueue.ToArray()
    $extraList = for ($qi = $maxConsumable; $qi -lt $tempArr.Count; $qi++) { "'$($tempArr[$qi])'" }
    Write-Host "${_yw}gitbox: warning -- extra arg(s) ignored: $($extraList -join ', ') -- did you forget to quote the full value?${_rs}"
}

$mutating = @($steps | Where-Object { $_.Flag -cmatch '[a-z]' })
$ran      = [System.Collections.Generic.List[string]]::new()

# --- Track B: matrix pre-check — skip flags whose work is already done ---
$skippableFlags = @('b','r','c','u','o','x','g')
$skipFlags = @{}
$skipReasons = @{
    'b' = 'already on feature branch'
    'r' = 'rename only applies to wip branches; use gitbox r standalone to rename any branch'
    'c' = 'nothing to commit'
    'u' = 'no unpushed commits'
    'o' = 'PR already open'
    'x' = 'PR open with passing checks'
    'g' = 'already on base branch'
}
if (@($mutating | Where-Object { $_.Flag -in $skippableFlags }).Count -gt 0) {
    $needsPR = @($mutating | Where-Object { $_.Flag -in @('o','x') }).Count -gt 0
    $spin = Start-Spinner "scanning state"
    $scanOut = if ($needsPR) {
        & (Join-Path $PSScriptRoot 'g-matrix-scan.ps1') 2>$null 6>&1
    } else {
        & (Join-Path $PSScriptRoot 'g-matrix-scan.ps1') -GitOnly 2>$null 6>&1
    }
    Stop-Spinner $spin
    $hashRaw = ($scanOut | Where-Object { "$_" -match '^[BFW]\|' }) | Select-Object -First 1
    if ($hashRaw -and "$hashRaw" -match '^([BFW])\|([^|]+)\|a\d+\|b\d+\|([PU])\|(PR[-DXOA]+)$') {
        $hClass = $Matches[1]; $hDirty = $Matches[2]; $hPush = $Matches[3]; $hPR = $Matches[4]
        $skipFlags['b'] = ($hClass -eq 'F') -and (-not $restSwitches.ContainsKey('Stack'))
        $skipFlags['r'] = ($hClass -ne 'W') -and ($mutating.Count -gt 1)
        $skipFlags['c'] = ($hDirty -eq 'c')
        $skipFlags['u'] = ($hPush  -eq 'P')
        $skipFlags['o'] = ($hPR -in @('PRO','PRA'))
        $skipFlags['x'] = ($hPR -in @('PRO','PRA'))
        $skipFlags['g'] = ($hClass -eq 'B')

        if ($hClass -eq 'B' -and ($steps | Where-Object { $_.Flag -eq 'c' }) -and -not ($steps | Where-Object { $_.Flag -eq 'b' })) {
            $_gcfg = Get-GitboxConfig
            if ($_gcfg.BaseBranch -ne $_gcfg.DefaultBranch) {
                $baseBranchName = git branch --show-current 2>$null
                Write-Host "gitbox: on base branch '$baseBranchName' -- create a feature branch first"
                Write-Host " run: gitbox b `"<name>`""
                exit 1
            }
        }

        if ($hClass -eq 'W' -and ($steps | Where-Object { $_.Flag -eq 'c' })) {
            $hasRename = [bool]($steps | Where-Object { $_.Flag -in @('r','b') })
            if (-not $AllowWip -and -not $hasRename) {
                $wipBranch = git branch --show-current 2>$null
                $newName = $null
                try {
                    $newName = Read-Host "gitbox: on wip branch '$wipBranch'. Enter new branch name (Enter to proceed as wip)"
                } catch {
                    Write-Host "gitbox: on wip branch '$wipBranch' -- rename first (gitbox r) or pass -AllowWip to proceed as-is"
                    exit 1
                }
                if ($newName) {
                    $newName | & (Join-Path $PSScriptRoot 'g-branch-rename.ps1')
                    if ($LASTEXITCODE -ne 0) { Write-Host "gitbox: rename failed"; exit $LASTEXITCODE }
                }
            }
        }
    }
}

# while loop (not foreach) so $i can hold position and retry the failed step after recovery
$i = 0
while ($i -lt $mutating.Count) {
    $step   = $mutating[$i]
    $flag   = $step.Flag
    $script = Join-Path $PSScriptRoot $step.Info.Script
    $name   = $step.Info.Script -replace '\.ps1$','' -replace '^g-',''

    if ($skipFlags.ContainsKey($flag) -and $skipFlags[$flag]) {
        Write-Host "${_d} skip $flag ($name): $($skipReasons[$flag])${_rs}"
        $ran.Add($flag)
        $i++
        continue
    }

    $forceArg    = if ($step.Info.Force) { @{ Force = $true } } else { @{} }
    $stepSwitches = @{}
    if ($step.Info.Switches) {
        foreach ($sw in $step.Info.Switches) {
            if ($restSwitches.ContainsKey($sw)) { $stepSwitches[$sw] = $true }
        }
    }
    # g followed by z: stash must not be popped onto base — preserve it for the feature branch
    if ($flag -eq 'g' -and ($steps | Where-Object { $_.Flag -eq 'z' })) {
        $stepSwitches['NoStashPop'] = $true
    }
    $splatArgs = $forceArg + $stepSwitches
    $stepLines = [System.Collections.Generic.List[string]]::new()
    if ($step.Info.NeedsArg -eq $true) {
        $rawOut = $argQueue.Dequeue() | & $script @splatArgs 6>&1
    } elseif ($step.Info.NeedsArg -eq 'optional' -and $argQueue.Count -gt 0) {
        $rawOut = $argQueue.Dequeue() | & $script @splatArgs 6>&1
    } else {
        $rawOut = & $script @splatArgs 6>&1
    }
    $rawOut | ForEach-Object { Write-Host "$_"; [void]$stepLines.Add("$_") }

    $stepExit = $LASTEXITCODE
    if ($stepExit -ne 0) {
        # Consult matrix-resolve; each recovered flag is added to $ran so it cannot be reused (loop guard)
        $recovered   = $false
        $stepOut     = $stepLines -join "`n"
        $errorVector = if ($stepOut) { Resolve-OutputToVector -Output $stepOut } else { $null }

        $rSpin    = Start-Spinner "scanning state"
        $scanOut  = & (Join-Path $PSScriptRoot 'g-matrix-scan.ps1') 2>$null 6>&1
        Stop-Spinner $rSpin
        $hashLine = ($scanOut | Where-Object { "$_" -match '^[BFW]\|' }) | Select-Object -First 1
        if ($hashLine) {
            $r = Resolve-MatrixAction -Hash "$hashLine" -ErrorVector $errorVector
            if ($r -and $r.Action) {
                Write-Host "${_yw} matrix suggests: next: $($r.Action)${_rs}"
                if ($r.Action -match 'gitbox\s+([a-z]+)') {
                    $suggestion   = $Matches[1]
                    $recovFlagStr = if ($WorkflowRegistry.Contains($suggestion)) { $WorkflowRegistry[$suggestion] } else { $suggestion }
                    $recoveryFlag = [string]($recovFlagStr.ToCharArray() |
                        Where-Object { $FlagMap.Contains([string]$_) -and [string]$_ -notin $ran.ToArray() } |
                        Select-Object -First 1)
                    if ($recoveryFlag) {
                        $rInfo   = $FlagMap[$recoveryFlag]
                        $rName   = $rInfo.Script -replace '\.ps1$','' -replace '^g-',''
                        $rScript = Join-Path $PSScriptRoot $rInfo.Script

                        # Interactive: confirm before proceeding (no silent action in attended sessions).
                        # Non-interactive: auto-proceed (no user to ask).
                        $confirmed   = $false
                        $interactive = $false
                        try {
                            $confirm     = Read-Host " recover with $recoveryFlag ($rName)? [Y/n]"
                            $interactive = $true
                            $confirmed   = ($confirm -eq '' -or $confirm -match '^[Yy]')
                        } catch {
                            $confirmed = $true   # non-interactive: auto-proceed
                        }

                        if (-not $confirmed) {
                            $remaining = (@($mutating | Where-Object { $_.Flag -notin $ran.ToArray() }) | ForEach-Object { $_.Flag }) -join ''
                            Write-Host "${_yw} recovery skipped -- remaining: $remaining${_rs}"
                        } else {
                            $rSkipped = $false
                            if ($rInfo.NeedsArg -eq $true) {
                                $rArg = $null
                                if ($interactive) {
                                    $rArg = Read-Host " arg for $recoveryFlag ($rName)"
                                }
                                if ($rArg) {
                                    Write-Host "${_cy} running $recoveryFlag ($rName) [interactive]${_rs} ..."
                                    $rArg | & $rScript
                                } else {
                                    # Running without a required arg throws ParameterBindingException which does
                                    # not set $LASTEXITCODE, producing a false-positive recovery and a retry loop.
                                    Write-Host "${_yw} recovery skipped -- $recoveryFlag ($rName) requires an arg${_rs}"
                                    $rSkipped = $true
                                }
                            } else {
                                $modeTag = if ($interactive) { '' } else { ' [non-interactive]' }
                                Write-Host "${_cy} running $recoveryFlag ($rName)${_rs}${modeTag} ..."
                                & $rScript
                            }
                            if (-not $rSkipped -and $LASTEXITCODE -eq 0) {
                                $recovered = $true
                                $ran.Add($recoveryFlag)
                                Write-Host "${_gn} recovered -- retrying $flag ($name)${_rs}"
                            }
                        }
                    }
                }
            }
        }

        if (-not $recovered) {
            $notRun    = @($mutating | Where-Object { $_.Flag -notin $ran.ToArray() -and $_.Flag -ne $flag }) | ForEach-Object { $_.Flag }
            $notRunStr = if ($notRun) { " |not run: $($notRun -join '')" } else { '' }
            Write-Host "${_rd}gitbox $($Spec): step $flag ($name) failed${_rs}"
            Write-Host "${_rd}halted at $flag$notRunStr${_rs}"
            exit $stepExit
        }
        # $i intentionally not advanced — retry the failed step on next iteration
        continue
    }

    $ran.Add($flag)
    $i++
}

# --- Execute diagnostic steps ---
$diag = @($steps | Where-Object { $_.Flag -cmatch '[A-Z]' })
foreach ($step in $diag) {
    switch ($step.Flag) {
        'W' {
            Write-Host 'Workflow registry:'
            foreach ($wfName in $WorkflowRegistry.Keys) {
                $wFlags  = $WorkflowRegistry[$wfName]
                $allCaps = [System.Collections.Generic.HashSet[string]]::new()
                foreach ($f in $wFlags.ToCharArray()) {
                    $si = $FlagMap["$f"]
                    if ($si.Script) {
                        $sp = Join-Path $PSScriptRoot $si.Script
                        if (Test-Path $sp) {
                            foreach ($cap in (Get-ScriptCapabilities -Path $sp)) { [void]$allCaps.Add($cap) }
                        }
                    }
                }
                $covers = foreach ($dim in ($GapRequirements.Keys | Sort-Object)) {
                    $req = $GapRequirements[$dim]
                    if (@($req | Where-Object { $_ -in $allCaps }).Count -eq $req.Count) { $dim }
                }
                $capsStr   = if ($allCaps.Count) { ($allCaps | Sort-Object) -join ' ' } else { '(none)' }
                $coversStr = if ($covers)         { $covers -join ' '                 } else { '(none)' }
                Write-Host (" {0,-8} = {1,-6} caps: {2}" -f $wfName, $wFlags, $capsStr)
                Write-Host (" {0,-8} {1,-6} covers: {2}" -f '', '', $coversStr)
            }
        }
        'O' { & (Join-Path $PSScriptRoot 'g-optimization.ps1') }
        'H' {
            $switchArgs = @{}
            foreach ($a in $Rest) { if ("$a" -match '^-([A-Za-z]\w*)$') { $switchArgs[$Matches[1]] = $true } }
            & (Join-Path $PSScriptRoot $step.Info.Script) @switchArgs
        }
        default {
            & (Join-Path $PSScriptRoot $step.Info.Script)
        }
    }
}
exit 0