Commands/Out-Git.ps1

function Out-Git
{
    <#
    .Synopsis
        Outputs Git to PowerShell
    .Description
        Outputs Git as PowerShell Objects.

        Git Output can be provided by any number of extensions to Out-Git.

        Extensions use two attributes to indicate if they should be run:

        ~~~PowerShell
        [Management.Automation.Cmdlet("Out","Git")] # This signals that this is an extension for Out-Git
        [ValidatePattern("RegularExpression")] # This is run on $GitCommand to determine if the extension should run.
        ~~~
    .LINK
        Invoke-Git
    .Example
        # Log entries are returned as objects, with properties and methods.
        git log -n 1 | Get-Member
    .Example
        # Status entries are converted into objects.
        git status
    .Example
        # Display untracked files.
        git status | Select-Object -ExpandProperty Untracked
    .Example
        # Display the list of branches, as objects.
        git branch
    .NOTES
        Out-Git will generate two events upon completion. They will have the source identifiers of "Out-Git" and "Out-Git $GitArgument"
    #>

    [CmdletBinding(PositionalBinding=$false)]
    param(
    # One or more output lines from Git.
    [Parameter(ValueFromPipeline)]
    [Alias('GitOutputLines')]
    [string[]]
    $GitOutputLine,

    # The arguments that were passed to git.
    [string[]]
    $GitArgument,

    # The root of the current git repository.
    [string]
    $GitRoot,

    # The timestamp. This can be used for tracking. Defaults to [DateTime]::Now
    [DateTime]
    $TimeStamp = [DateTime]::Now
    )

    begin {
        # First, we need to determine what the combined git command was.
        # Luckily, this is easy: just combine "git" with the list of git argument
        $gitCommand = @('git') + $GitArgument[0..$GitArgument.Length] -join ' '

        # Now we need to see if we have have a cached for extension mapping.
        if (-not $script:GitExtensionMappingCache) {
            $script:GitExtensionMappingCache = @{} # If we don't, create one.
        }

        if (-not $script:GitExtensionMappingCache[$gitCommand]) { # If we don't have a cached extension list

            # let's initialize an empty variable to keep any extension validation errors.
            $extensionValidationErrors = $null
            # Then we create a hashtable containing the parameters to Get-UGitExtension:
            $uGitExtensionParams = @{
                    CommandName   = $MyInvocation.MyCommand      # We want extensions for this command
                    ValidateInput = $gitCommand                  # that are valid, given $GitCommand.

            }

            # If -Verbose is -Debug is set, we will want to populate extensionValidationErrors
            if ($VerbosePreference -ne 'silentlyContinue' -or
                $DebugPreference -ne 'silentlyContinue') {
                $uGitExtensionParams.ErrorAction   = 'SilentlyContinue'           # We do not want to display errors
                $uGitExtensionParams.ErrorVariable = 'extensionValidationErrors'  # we want to redirect them into $extensionValidationErrors.
                $uGitExtensionParams.AllValid      = $true                        # and we want to see that all of the validation attributes are correct.
            } else {
                $uGitExtensionParams.ErrorAction = 'Ignore'
            }

            # Now we get a list of git output extensions and store it in the cache.
            $script:GitExtensionMappingCache[$gitCommand] = $gitOutputExtensions = @(Get-UGitExtension @uGitExtensionParams)

            # If any of them had errors, and we want to see the -Verbose channel
            if ($extensionValidationErrors -and $VerbosePreference -ne 'silentlyContinue')  {
                foreach ($validationError in $extensionValidationErrors) {
                    Write-Verbose "$validationError" # write the validation errors to verbose.
                    # It should be noted that there will almost always be validation errors,
                    # since most extensions will not apply to a given $GitCommand
                }
            }
        } else {
            # If there was already an extension cached, we can skip the previous steps and just reuse the cached extensions.
            $gitOutputExtensions = $script:GitExtensionMappingCache[$gitCommand]
        }

        # Next we want to create a collection of SteppablePipelines.
        # These allow us to run the begin/process/end blocks of each Extension.
        $steppablePipelines =
            [Collections.ArrayList]::new(@(if ($gitOutputExtensions) {
                foreach ($ext in $gitOutputExtensions) {
                    $scriptCmd = {& $ext}
                    $scriptCmd.GetSteppablePipeline()
                }
            }))


        # Next we need to start any steppable pipelines.
        # Each extension can break, continue in it's begin block to indicate it should not be processed.
        $spi = 0
        $spiToRemove = @()
        $beginIsRunning = $false
        # Walk over each steppable pipeline.
        :NextExtension foreach ($steppable in $steppablePipelines) {
            if ($beginIsRunning) { # If beginIsRunning is set, then the last steppable pipeline continued
                $spiToRemove+=$steppablePipelines[$spi] # so mark it to be removed.
            }
            $beginIsRunning = $true      # Note that beginIsRunning=$false,
            try {
                $steppable.Begin($true) # then try to run begin
            } catch {
                $PSCmdlet.WriteError($_) # Write any exceptions as errors
            }
            $beginIsRunning = $false     # Note that beginIsRunning=$false
            $spi++                       # and increment the index.
        }

        # If this is still true, an extenion used 'break', which signals to stop processing of it any subsequent pipelines.
        if ($beginIsRunning) {
            $spiToRemove += @(for (; $spi -lt $steppablePipelines.Count; $spi++) {
                $steppablePipelines[$spi]
            })
        }

        # Remove all of the steppable pipelines that signaled they no longer wish to run.
        foreach ($tr in $spiToRemove) {
            $steppablePipelines.Remove($tr)
        }

        $AllGitOutput    = [Collections.Queue]::new()
        $ProcessedOutput = [Collections.Queue]::new()
        $OutputLineCount = 0
        $errorTypeNames  = @()
    }

    process {
        # Walk over each output.

        foreach ($out in $GitOutputLine) {
            $OutputLineCount++
            # If the out was a literal string of 'System.Management.Automation.RemoteException',
            if ("$out" -eq "System.Management.Automation.RemoteException") {
                # ignore it and continue (these things happen with some exes from time to time).
                continue
            }

            try {
                $AllGitOutput.Enqueue($out)
                
                # Wrap the output in a PSObject
                $gitOut = [PSObject]::new($out)
            } catch {                
                # [AMSI](https://learn.microsoft.com/en-us/windows/win32/amsi/how-amsi-helps) will prevent creation of a PSObject from a string if it is deemed malicious
                # Therefore, if we could not create the object, complain with the exact line and keep moving.
                Write-Error "Line $outputLineCount : $_"
                continue
            }

            # Next, clear it's typenames and determine an automatic typename.
            # The first typename is the complete set of arguments ( separated by periods )
            # Followed by each smaller set of arguments, separated by periods
            # Followed by a PSTypeName of 'git'
            # Thus, for example, git clone $repo
            # Would have the typenames of :"git.clone.$repo.output", "git.clone.output","git.output"
            $gitOut.pstypenames.clear()
            for ($n = $GitArgument.Length - 2 ; $n -ge 0; $n--) {
                $gitOut.pstypenames.add(@('git') + $GitArgument[0..$n]  + @('output') -join '.')
            }
            $gitOut.pstypenames.add('git.output')

            # All gitOutput should attach the original output line, as well as the command that produced that line.
            $gitOut.psobject.properties.add([PSNoteProperty]::new('GitOutput',"$out"))
            $gitOut.psobject.properties.add([PSNoteProperty]::new('GitCommand',
                $(@('git') + $GitArgument) -join ' ')
            )

            # If the output started with "error" or "fatal"
            if ("$out" -match "^(?:error|fatal):") {
                $exception = [Exception]::new($("$out" -replace '^(?:error|fatal):')) # Create an exception
                $errorRecord = [Management.Automation.ErrorRecord]::new($exception,"$GitCommand", 'NotSpecified',$gitOut)
                $errorTypeNames = @($gitOut.pstypenames -replace 'output','error')
                foreach ($typename in $errorTypeNames) {
                    $errorRecord.pstypenames.add($typename)
                }
                
                $PSCmdlet.WriteError( # and write an error using $psCmdlet (this simplifies the displayed callstack).
                    $errorRecord
                )
                # If there was an error, cancel all steppable pipelines (thus stopping any extensions)
                $steppablePipelines = @()
                continue # then move onto the next output.
            } else {
                Write-Verbose "$out"
            }

            if ("$out" -match '^hint:') {
                Write-Warning ("$out" -replace '^hint:')
                continue
            }

            if (-not $steppablePipelines) {
                # If we do not have steppable pipelines, output directly
                if ($errorTypeNames -and $gitOut.pstypenames) {
                    foreach ($typeName in $errorTypeNames) {
                        $gitOut.pstypenames.add($typename)
                    }
                    $gitOut
                } else {
                    $gitOut
                }                
            }
            else {
                # If we have steppable pipelines, then we have to do a similar operation as we did for begin.
                $spi = 0
                $spiToRemove = @()
                $processIsRunning = $false
                # We have to walk thru each steppable pipeline,
                :NextExtension foreach ($steppable in $steppablePipelines) {
                    if ($processIsRunning) {  # if $ProcessIsRunning, the pipeline was skipped with continue.
                        $spiToRemove+=$steppablePipelines[$spi] # and we should add it to the list of pipelines to remove
                    }
                    $processIsRunning = $true # Set $processIsRunning,
                    try {
                        $steppable.Process($gitOut) | & {
                            process {
                                $ProcessedOutput.Enqueue($_)
                                $_
                            }
                        } # attempt to run process, using the $gitOut object.
                    } catch {
                        $PSCmdlet.WriteError($_)    # (catch any exceptions and write them as errors).
                    }
                    $processIsRunning = $false # Set $processIsRunning to $false for the next step.
                }


                if ($processIsRunning) {  # If $ProcessIsRunning was true, the extension used break
                    # which should signal cancellation of all subsequent extensions.
                    $spiToRemove += @(for (; $spi -lt $steppablePipelines.Count; $spi++) {
                        $steppablePipelines[$spi]
                    })


                    $gitOut # We will also output the gitOut object in this case.
                }

                # Remove any steppable pipelines we need to remove.
                foreach ($tr in $spiToRemove) { $steppablePipelines.Remove($tr) }
            }
        }
    }

    end {
        $global:lastGitOutput = $AllGitOutput.ToArray()

        # End remaining steppable pipelines need to end.
        # Ending does not support the cancellation of other extensions.
        foreach ($steppable in $steppablePipelines) {
            try {
                $steppable.End() | & { process {
                    $ProcessedOutput.Enqueue($_)
                    $_
                }}
            } catch {
                Write-Error -ErrorRecord $_
            }
        }

        if (-not $global:gitHistory -or
            $global:gitHistory -isnot [Collections.IDictionary]) {
            $global:gitHistory = [Ordered]@{}
        }
        $messageData = [Ordered]@{
            OutputObject  = $ProcessedOutput.ToArray()
            GitOutputLine = $AllGitOutput.ToArray()
            GitArgument   = $GitArgument
            GitCommand    = @(@("git") + $GitArgument) -join ' '
            GitRoot       = $GitRoot
            TimeStamp     = $TimeStamp
        }

        $eventSourceIds = @("Out-Git","Out-Git $gitArgument")

        $null =
            foreach ($sourceIdentifier in $eventSourceIds) {
                New-Event -SourceIdentifier $sourceIdentifier -MessageData $messageData
            }

        $global:gitHistory["$($MyInvocation.HistoryId)::$GitRoot::$GitArgument"] = $messageData
    }
}