PSFzf.Git.ps1


$script:GitKeyHandlers = @()

$script:foundGit = $false
$script:bashPath = $null
$script:grepPath = $null

if ($PSVersionTable.PSEdition -eq 'Core') {
    $script:pwshExec = "pwsh"
}
else {
    $script:pwshExec = "powershell"
}

$script:IsWindowsCheck = ($PSVersionTable.PSVersion.Major -le 5) -or $IsWindows

if ($RunningInWindowsTerminal -or -not $script:IsWindowsCheck) {
    $script:filesString = '📁 Files'
    $script:hashesString = '🍡 Hashes'
    $script:allBranchesString = '🌳 All branches'
    $script:branchesString = '🌲 Branches'
    $script:tagsString = '📛 Tags'
    $script:stashesString = '🥡 Stashes'
}
else {
    $script:filesString = 'Files'
    $script:hashesString = 'Hashes'
    $script:allBranchesString = 'All branches'
    $script:branchesString = 'Branches'
    $script:tagsString = 'Tags'
    $script:stashesString = 'Stashes'
}

function Get-GitFzfArguments() {
    # take from https://github.com/junegunn/fzf-git.sh/blob/f72ebd823152fa1e9b000b96b71dd28717bc0293/fzf-git.sh#L89
    return @{
        Ansi          = $true
        Layout        = "reverse"
        Multi         = $true
        Height        = '50%'
        MinHeight     = 20
        Border        = $true
        Color         = 'header:italic:underline'
        PreviewWindow = 'right,50%,border-left'
        Bind          = @('ctrl-/:change-preview-window(down,50%,border-top|hidden|)')
    }
}

function SetupGitPaths() {
    if (-not $script:foundGit) {
        if ($IsLinux -or $IsMacOS) {
            # TODO: not tested on Mac
            $script:foundGit = $null -ne $(Get-Command git -ErrorAction Ignore)
            $script:bashPath = 'bash'
            $script:grepPath = 'grep'
        }
        else {
            $gitInfo = Get-Command git.exe -ErrorAction Ignore
            $script:foundGit = $null -ne $gitInfo
            if ($script:foundGit) {
                # Detect if scoop is installed
                $script:scoopInfo = Get-Command scoop -ErrorAction Ignore
                if ($null -ne $script:scoopInfo) {
                    # Detect if git is installed using scoop (using shims)
                    if ($gitInfo.Source -match 'scoop[\\/]shims') {
                        # Get the proper git position relative to scoop shims" position
                        $gitInfo = Get-Command "$($gitInfo.Source)\..\..\apps\git\current\bin\git.exe"
                    }
                }
                $gitPathLong = Split-Path (Split-Path $gitInfo.Source -Parent) -Parent
                # hack to get short path:
                $a = New-Object -ComObject Scripting.FileSystemObject
                $f = $a.GetFolder($gitPathLong)
                $script:bashPath = Join-Path $f.ShortPath "bin\bash.exe"
                $script:bashPath = Resolve-Path $script:bashPath
                $script:grepPath = Join-Path ${gitPathLong} "usr\bin\grep.exe"
            }
        }
    }
    return $script:foundGit
}

function SetGitKeyBindings($enable) {
    if ($enable) {
        if (-not $(SetupGitPaths)) {
            Write-Error "Failed to register git key bindings - git executable not found"
            return
        }

        if (Get-Command Set-PSReadLineKeyHandler -ErrorAction Ignore) {
            @('ctrl+g,ctrl+b', 'Select Git branches via fzf', { Update-CmdLine $(Invoke-PsFzfGitBranches) }), `
            @('ctrl+g,ctrl+f', 'Select Git files via fzf', { Update-CmdLine $(Invoke-PsFzfGitFiles) }), `
            @('ctrl+g,ctrl+h', 'Select Git hashes via fzf', { Update-CmdLine $(Invoke-PsFzfGitHashes) }), `
            @('ctrl+g,ctrl+p', 'Select Git pull requests via fzf', { Update-CmdLine $(Invoke-PsFzfGitPulLRequests) }), `
            @('ctrl+g,ctrl+s', 'Select Git stashes via fzf', { Update-CmdLine $(Invoke-PsFzfGitStashes) }), `
            @('ctrl+g,ctrl+t', 'Select Git tags via fzf', { Update-CmdLine $(Invoke-PsFzfGitTags) }) `
            | ForEach-Object {
                $script:GitKeyHandlers += $_[0]
                Set-PSReadLineKeyHandler -Chord $_[0] -Description $_[1] -ScriptBlock $_[2]
            }
        }
        else {
            Write-Error "Failed to register git key bindings - PSReadLine module not loaded"
            return
        }
    }
}

function RemoveGitKeyBindings() {
    $script:GitKeyHandlers | ForEach-Object {
        Remove-PSReadLineKeyHandler -Chord $_
    }
}

function IsInGitRepo() {
    git rev-parse HEAD 2>&1 | Out-Null
    return $?
}

function Get-ColorAlways($setting = ' --color=always') {
    if ($RunningInWindowsTerminal -or -not $IsWindowsCheck) {
        return $setting
    }
    else {
        return ''
    }
}

function Get-HeaderStrings() {
    $header = "CTRL-A (Select all) / CTRL-D (Deselect all) / CTRL-T (Toggle all)"
    $keyBinds = 'ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all'
    return $Header, $keyBinds
}

function Update-CmdLine($result) {
    InvokePromptHack
    if ($result.Length -gt 0) {
        $result = $result -join " "
        [Microsoft.PowerShell.PSConsoleReadLine]::Insert($result)
    }
}
function Invoke-PsFzfGitFiles() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $previewCmd = "${script:bashPath} \""" + $(Join-Path $PsScriptRoot 'helpers/PsFzfGitFiles-Preview.sh') + "\"" {-1}" + $(Get-ColorAlways) + " \""$($pwd.ProviderPath)\"""
    $result = @()

    $headerStrings = Get-HeaderStrings
    $gitCmdsHeader = "`nALT-S (Git add) / ALT-R (Git reset)"
    $headerStr = $headerStrings[0] + $gitCmdsHeader + "`n`n"
    $statusCmd = "git $(Get-ColorAlways '-c color.status=always') status --short"

    $reloadBindCmd = "reload($statusCmd)"
    $stageScriptPath = Join-Path $PsScriptRoot 'helpers/PsFzfGitFiles-GitAdd.sh'
    $gitStageBind = "alt-s:execute-silent(" + """${script:bashPath}"" '${stageScriptPath}' {+2..})+down+${reloadBindCmd}"
    $resetScriptPath = Join-Path $PsScriptRoot 'helpers/PsFzfGitFiles-GitReset.sh'
    $gitResetBind = "alt-r:execute-silent(" + """${script:bashPath}"" '${resetScriptPath}' {+2..})+down+${reloadBindCmd}"

    $fzfArguments = Get-GitFzfArguments
    $fzfArguments['Bind'] += $headerStrings[1], $gitStageBind, $gitResetBind
    Invoke-Expression "& $statusCmd" | `
        Invoke-Fzf @fzfArguments `
        -BorderLabel "$script:filesString" `
        -Preview "$previewCmd" -Header $headerStr | `
        foreach-object {
        $result += $_.Substring('?? '.Length)
    }

    $result
}
function Invoke-PsFzfGitHashes() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $previewCmd = "${script:bashPath} \""" + $(Join-Path $PsScriptRoot 'helpers/PsFzfGitHashes-Preview.sh') + "\"" {}" + $(Get-ColorAlways) + " \""$pwd\"""
    $result = @()

    $fzfArguments = Get-GitFzfArguments
    & git log --date=short --format="%C(green)%C(bold)%cd %C(auto)%h%d %s (%an)" $(Get-ColorAlways).Trim() --graph | `
        Invoke-Fzf @fzfArguments -NoSort  `
        -BorderLabel "$script:hashesString" `
    -Preview "$previewCmd" | ForEach-Object {
        if ($_ -match '\d\d-\d\d-\d\d\s+([a-f0-9]+)\s+') {
            $result += $Matches.1
        }
    }

    $result
}

function Invoke-PsFzfGitBranches() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $fzfArguments = Get-GitFzfArguments
    $fzfArguments['PreviewWindow'] = 'down,border-top,40%'
    $gitBranchesHelperPath = Join-Path $PsScriptRoot 'helpers/PsFzfGitBranches.sh'
    $ShortcutBranchesAll = "ctrl-a:change-prompt" + "($script:allBranchesString> )+reload(" + """${script:bashPath}"" '${gitBranchesHelperPath}' all-branches)"
    $fzfArguments['Bind'] += 'ctrl-/:change-preview-window(down,70%|hidden|)', $ShortcutBranchesAll

    $previewCmd = "${script:bashPath} \""" + $(Join-Path $PsScriptRoot 'helpers/PsFzfGitBranches-Preview.sh') + "\"" {}"
    $result = @()
    # use pwsh to prevent bash from trying to write to host output:
    $branches = & $script:pwshExec -NoProfile -NonInteractive -Command "& ${script:bashPath} '$gitBranchesHelperPath' branches"
    $branches |
    Invoke-Fzf @fzfArguments -Preview "$previewCmd" -BorderLabel "$script:branchesString" -HeaderLines 2 -Tiebreak begin -ReverseInput | `
        ForEach-Object {
        $result += $($_.Substring('* '.Length) -split ' ')[0]
    }

    $result
}

function Invoke-PsFzfGitTags() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $fzfArguments = Get-GitFzfArguments
    $fzfArguments['PreviewWindow'] = 'right,70%'
    $previewCmd = "git show --color=always {}"
    $result = @()
    git tag --sort -version:refname |
    Invoke-Fzf @fzfArguments -Preview "$previewCmd" -BorderLabel "$script:tagsString" | `
        ForEach-Object {
        $result += $_
    }

    $result
}

function Invoke-PsFzfGitStashes() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $fzfArguments = Get-GitFzfArguments
    $fzfArguments['Bind'] += 'ctrl-x:execute-silent(git stash drop {1})+reload(git stash list)'
    $header = "CTRL-X (drop stash)`n`n"
    $previewCmd = 'git show --color=always {1}'

    $result = @()
    git stash list --color=always |
    Invoke-Fzf @fzfArguments -Header $header -Delimiter ':' -Preview "$previewCmd" -BorderLabel "$script:stashesString" | `
        ForEach-Object {
        $result += $_.Split(':')[0]
    }

    $result
}

function Invoke-PsFzfGitPullRequests() {
    if (-not (IsInGitRepo)) {
        return
    }

    if (-not $(SetupGitPaths)) {
        Write-Error "git executable could not be found"
        return
    }

    $filterCurrentUser = $true
    $reloadPrList = $false

    # loop due to requesting possibly selecting current user
    do {
        # find the repo remote URL
        $remoteUrl = git config --get remote.origin.url

        # GitHub
        if ($remoteUrl -match 'github.com') {
            $script:ghCmdInfo = Get-Command gh -ErrorAction Ignore
            if ($null -ne $script:ghCmdInfo) {
                if ($filterCurrentUser) {
                    $currentUser = Invoke-Expression "gh api user --jq '.login'"
                    $listAllPrsCmdJson = Invoke-Expression "gh pr list --json id,author,title,number --author $currentUser"
                }
                else {
                    $currentUser = $null
                    $listAllPrsCmdJson = Invoke-Expression "gh pr list --json id,author,title,number"
                }

                $objs = $listAllPrsCmdJson | ConvertFrom-Json | ForEach-Object {
                    [PSCustomObject]@{
                        PR      = "$($PSStyle.Foreground.Green)" + $_.number
                        Title   = "$($PSStyle.Foreground.Magenta)" + $_.title
                        Creator = "$($PSStyle.Foreground.Yellow)" + $_.author.login
                    }
                }
            }
            else {
                Write-Error "Repo is a GitHub repo and gh command not found"
                return
            }
            $webCmd = 'gh pr view {1} --web'
            $previewCmd = 'gh pr view {1} && gh pr diff {1}'
            $checkoutCmd = 'gh pr checkout {0}'
        }
        # Azure DevOps
        elseif ($remoteUrl -match 'dev.azure.com|visualstudio.com') {
            $script:azCmdInfo = Get-Command az -ErrorAction Ignore
            if ($null -ne $script:azCmdInfo) {
                if ($filterCurrentUser) {
                    $currentUser = Invoke-Expression "az account show --query user.name --output tsv"
                    $listAllPrsCmdJson = Invoke-Expression $('az repos pr list --status "active" --query "[].{title: title, number: pullRequestId, creator: createdBy.uniqueName}"' + "--creator $currentUser")
                }
                else {
                    $currentUser = $null
                    $listAllPrsCmdJson = Invoke-Expression 'az repos pr list --status "active" --query "[].{title: title, number: pullRequestId, creator: createdBy.uniqueName}"'
                }

                $objs = $listAllPrsCmdJson | ConvertFrom-Json | ForEach-Object {
                    [PSCustomObject]@{
                        PR      = "$($PSStyle.Foreground.Green)" + $_.number
                        Title   = "$($PSStyle.Foreground.Magenta)" + $_.title
                        Creator = "$($PSStyle.Foreground.Yellow)" + $_.creator
                    }
                }
            }
            else {
                Write-Error "Repo is an Azure DevOps repo and az command not found"
                return
            }
            $webCmd = 'az repos pr show --id {1} --open --output none'
            # currently errors on query. Need to fix instead of output everything
            #$previewCmd = 'az repos pr show --id {1} --query "{Created:creationDate, Closed:closedDate, Creator:createdBy.displayName, PR:codeReviewId, Title:title, Repo:repository.name, Reviewers:join('', '',reviewers[].displayName), Source:sourceRefName, Target:targetRefName}" --output yamlc'
            $previewCmd = 'az repos pr show --id {1} --output yamlc'
            $checkoutCmd = 'az repos pr checkout --id {0}'
        }

        $fzfArguments = Get-GitFzfArguments
        $fzfArguments['Bind'] += 'ctrl-o:execute-silent(' + $webCmd + ')'
        $header = "CTRL-O (open in browser) / CTRL-X (checks) / CTRL+U (toggle user filter) / CTRL+P (checkout PR)`n`n"

        $prevCLICOLOR_FORCE = $env:CLICOLOR_FORCE
        if ($PSStyle) {
            $prevOutputRendering = $PSStyle.OutputRendering
        }


        $env:CLICOLOR_FORCE = 1 # make gh show keep colors
        if ($PSStyle) {
            $PSStyle.OutputRendering = 'Ansi'
        }

        try {
            $borderLabel = "Pull Requests"
            if ($currentUser) {
                $borderLabel += " by $currentUser"
            }
            $result = $objs | out-string -Stream  | `
                Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | `
                Invoke-Fzf @fzfArguments -Expect "ctrl-x,ctrl-u,ctrl-p" -Header $header -Preview "$previewCmd" -HeaderLines 2 -BorderLabel $borderLabel

            if ($result -is [array]) {
                if ($result.Length -ge 2) {
                    $prId = $result[1].Split(' ')[0] # get the PR ID
                }
                else {
                    $prId = $null
                }
                $reloadPrList = $result[0] -eq 'ctrl-u' # reload if user filter toggled
            }
            else {
                $reloadPrList = $result -eq 'ctrl-u' # reload if user filter toggled
            }
            $checks = $null

            # reload with user filter toggled:
            if ($reloadPrList) {
                $filterCurrentUser = -not $filterCurrentUser
            }
            # checkout PR:
            elseif ($result[0] -eq 'ctrl-p') {
                Write-Warning "Checking out PR $prId into $($(Get-Location).Path) ..."
                Invoke-Expression ($checkoutCmd -f $prId)
            }
            # open checks for PR:
            elseif ($result[0] -eq 'ctrl-x') {
                if ($remoteUrl -match 'github.com') {
                    $env:CLICOLOR_FORCE = $prevCLICOLOR_FORCE
                    $checksCmd = "gh pr view $prId --json ""statusCheckRollup"""
                    $checksJsonTxt = Invoke-Expression $checksCmd
                    $checksJson = $checksJsonTxt | ConvertFrom-Json
                    $checks = $checksJson.statusCheckRollup | ForEach-Object {
                        if ($_.status -eq 'COMPLETED') {
                            if ($_.conclusion -eq 'SUCCESS') {
                                $status = "$($PSStyle.Foreground.Green)" + '? Success'
                            }
                            else {
                                $status = "$($PSStyle.Foreground.Red)" + '? Failed'
                            }
                        }
                        else {
                            $status = "$($PSStyle.Foreground.Yellow)" + '?'
                        }
                        [PSCustomObject]@{
                            Status = $status
                            Check  = "$($PSStyle.Foreground.Magenta)" + $_.name
                            Link   = $_.detailsUrl
                        }
                    }
                    #$runCheckCmd = $null
                    $runCheckCmd = 'echo running '
                }
                elseif ($remoteUrl -match 'dev.azure.com|visualstudio.com') {
                    $checksCmd = "az repos pr policy list --id $prId --output json"
                    $checksJsonTxt = Invoke-Expression $checksCmd
                    $checksJson = $checksJsonTxt | ConvertFrom-Json

                    # only worried about blocking checks, for now:
                    $checks = $checksJson | Where-Object { $_.configuration.isBlocking } | ForEach-Object {
                        $context = $_.context
                        $settings = $_.configuration.settings
                        $type = $_.configuration.type
                        $link = $remoteUrl, "pullrequest/$($prId)" -join '/' # default to opening PR in browser

                        # find check status:
                        switch ($_.status) {
                            'approved' {
                                $status = "$($PSStyle.Foreground.Green)" + '? Approved'
                            }
                            'rejected' {
                                $status = "$($PSStyle.Foreground.Red)" + '? Rejected'
                            }
                            'queued' {
                                if ($context -and $context.IsExpired) {
                                    $status = "$($PSStyle.Foreground.Red)" + '? Expired'
                                }
                                else {
                                    $status = "$($PSStyle.Foreground.BrightBlue)" + '?? Queued'
                                }
                            }
                            'running' {
                                $status = "$($PSStyle.Foreground.BrightBlue)" + '? Running'
                            }
                            default {
                                $status = $_.status # unknown status
                            }
                        }

                        # find check name and build link:
                        switch ($type.displayName) {
                            'Build' {
                                $check = $settings.displayName
                                if ([string]::IsNullOrWhiteSpace($check)) {
                                    $check = $context.buildDefinitionName
                                }
                                if ($context) {
                                    $buildId = $context.buildId
                                    $link = $remoteUrl.split('/_git/')[0], "_build/results?buildId=$buildId" -join '/'
                                }
                            }
                            'Status' {
                                $check = $settings.defaultDisplayName
                            }
                            default {
                                $check = $type.displayName
                            }
                        }
                        [PSCustomObject]@{
                            EvaluationId = "$($PSStyle.Foreground.Blue)" + $_.evaluationId
                            Status       = $status
                            Check        = "$($PSStyle.Foreground.Magenta)" + $check
                            Link         = $link
                        }
                    }

                    $runCheckCmd = "az repos pr policy queue --id $prId --output none --evaluation-id "
                }
            }

            # 2. Run the checks command, if selected in previous command:
            if ($null -ne $checks) {
                $fzfArguments = Get-GitFzfArguments
                #$fzfArguments['Bind'] += 'ctrl-r:execute(' + $runCheckCmd + ')'
                if ($runCheckCmd) {
                    $fzfArguments['Expect'] = "ctrl-r"
                    $header = "CTRL-R (run selected checks)`n`n"
                }
                else {
                    $header = "`n"
                }
                $env:CLICOLOR_FORCE = 1 # make gh show keep colors
                if ($PSStyle) {
                    $PSStyle.OutputRendering = 'Ansi'
                }

                $result = $checks | out-string -Stream  | `
                    Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | `
                    Invoke-Fzf @fzfArguments -Header $header -HeaderLines 2 -BorderLabel $('? Checks' + " for PR $prId")

                if ($runCheckCmd -and $result[0] -eq 'ctrl-r') {
                    $result = $result[1..($result.Length - 1)]
                    $result | ForEach-Object {
                        $cmd = $($runCheckCmd + $($_ -split ' ')[0])
                        Write-Warning "Running check using command '$cmd'..."
                        Invoke-Expression $cmd
                    }
                }
            }
        }
        finally {
            $env:CLICOLOR_FORCE = $prevCLICOLOR_FORCE
            if ($PSStyle) {
                $PSStyle.OutputRendering = $prevOutputRendering
            }
        }
    } while ($reloadPrList)

    $prId
}