Private/Git/Invoke-CgrNativeCommand.ps1

function Invoke-CgrNativeCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $FilePath,

        [string[]] $ArgumentList = @(),

        [string[]] $PrefixArgumentList = @(),

        [hashtable] $Environment = @{},

        [ValidateScript({
                $_ -eq [System.Threading.Timeout]::InfiniteTimeSpan -or $_ -gt [TimeSpan]::Zero
            })]
        [TimeSpan] $Timeout = [System.Threading.Timeout]::InfiniteTimeSpan,

        [System.Threading.CancellationToken] $CancellationToken = [System.Threading.CancellationToken]::None
    )

    $effectiveArgumentList = @($PrefixArgumentList) + @($ArgumentList)
    $startInfo = [System.Diagnostics.ProcessStartInfo]::new()
    $startInfo.FileName = $FilePath
    $startInfo.UseShellExecute = $false
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true

    foreach ($argument in $effectiveArgumentList) {
        [void] $startInfo.ArgumentList.Add($argument)
    }

    foreach ($name in $Environment.Keys) {
        if ($null -eq $Environment[$name]) {
            [void] $startInfo.Environment.Remove($name)
            continue
        }

        $startInfo.Environment[$name] = [string] $Environment[$name]
    }

    $process = [System.Diagnostics.Process]::new()
    $process.StartInfo = $startInfo
    $timeoutCancellationSource = $null
    $linkedCancellationSource = $null

    try {
        if (-not $process.Start()) {
            throw "Failed to start native command '$FilePath'."
        }

        $standardOutputTask = $process.StandardOutput.ReadToEndAsync()
        $standardErrorTask = $process.StandardError.ReadToEndAsync()
        $terminationKind = $null
        $cleanupDiagnostic = $null

        try {
            $hasFiniteTimeout = $Timeout -ne [System.Threading.Timeout]::InfiniteTimeSpan
            if (-not $hasFiniteTimeout -and -not $CancellationToken.CanBeCanceled) {
                $process.WaitForExit()
            }
            else {
                $effectiveCancellationToken = $CancellationToken

                if ($hasFiniteTimeout) {
                    $timeoutCancellationSource = [System.Threading.CancellationTokenSource]::new($Timeout)
                    if ($CancellationToken.CanBeCanceled) {
                        $linkedCancellationSource = [System.Threading.CancellationTokenSource]::CreateLinkedTokenSource(
                            $CancellationToken,
                            $timeoutCancellationSource.Token
                        )
                        $effectiveCancellationToken = $linkedCancellationSource.Token
                    }
                    else {
                        $effectiveCancellationToken = $timeoutCancellationSource.Token
                    }
                }

                $process.WaitForExitAsync($effectiveCancellationToken).GetAwaiter().GetResult()
            }
        }
        catch [System.OperationCanceledException] {
            if ($CancellationToken.IsCancellationRequested) {
                $terminationKind = 'Cancelled'
            }
            else {
                $terminationKind = 'TimedOut'
            }

            try {
                if (-not $process.HasExited) {
                    $process.Kill($true)
                    $process.WaitForExit()
                }
            }
            catch {
                $cleanupDiagnostic = $_.Exception.Message
            }
        }

        $standardOutputText = $standardOutputTask.GetAwaiter().GetResult()
        $standardErrorText = $standardErrorTask.GetAwaiter().GetResult()
        $standardOutput = [System.Collections.Generic.List[string]]::new()
        $standardError = [System.Collections.Generic.List[string]]::new()

        $standardOutputReader = [System.IO.StringReader]::new($standardOutputText)
        try {
            while ($null -ne ($line = $standardOutputReader.ReadLine())) {
                $standardOutput.Add($line)
            }
        }
        finally {
            $standardOutputReader.Dispose()
        }

        $standardErrorReader = [System.IO.StringReader]::new($standardErrorText)
        try {
            while ($null -ne ($line = $standardErrorReader.ReadLine())) {
                $standardError.Add($line)
            }
        }
        finally {
            $standardErrorReader.Dispose()
        }

        $standardOutputLines = @($standardOutput)
        $standardErrorLines = @($standardError)

        if ($null -ne $terminationKind) {
            if ($terminationKind -eq 'Cancelled') {
                $message = "Native command '$FilePath' was cancelled."
                $errorId = 'NativeCommandCancelled'
            }
            else {
                $message = "Native command '$FilePath' timed out after $($Timeout.TotalSeconds) second(s)."
                $errorId = 'NativeCommandTimedOut'
            }

            $exception = [System.OperationCanceledException]::new($message)
            $exception.Data['FilePath'] = $FilePath
            $exception.Data['StandardOutput'] = $standardOutputLines
            $exception.Data['StandardError'] = $standardErrorLines
            if ($terminationKind -eq 'TimedOut') {
                $exception.Data['Timeout'] = $Timeout
            }
            if (-not [string]::IsNullOrWhiteSpace($cleanupDiagnostic)) {
                $exception.Data['ProcessCleanupDiagnostic'] = $cleanupDiagnostic
            }

            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                $exception,
                $errorId,
                [System.Management.Automation.ErrorCategory]::OperationTimeout,
                $FilePath
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        [pscustomobject] @{
            PSTypeName = 'CopyGitHubRepo.NativeCommandResult'
            FilePath = $FilePath
            Arguments = $effectiveArgumentList
            ExitCode = $process.ExitCode
            StandardOutput = $standardOutputLines
            StandardError = $standardErrorLines
            Output = $standardOutputLines
            ErrorText = ($standardErrorLines | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
        }
    }
    finally {
        if ($null -ne $linkedCancellationSource) {
            $linkedCancellationSource.Dispose()
        }
        if ($null -ne $timeoutCancellationSource) {
            $timeoutCancellationSource.Dispose()
        }
        $process.Dispose()
    }
}