_publish/Git-PsRadar.psm1

<#
 
.SYNOPSIS
 
   A heads up display for git. A port of https://github.com/michaeldfallen/git-radar
 
.DESCRIPTION
 
    Provides an at-a-glance information about your git repo.
 
.LINK
 
   https://github.com/vincpa/git-psradar
 
#>


$arrows = @{upArrow = '↑';downArrow = '↓';rightArrow = '→';leftArrow = '←'; leftRightArrow = '↔'; stash = '≡'}
$arrows = New-Object –TypeName PSObject –Prop $arrows

$ScriptRoot = (Split-Path $MyInvocation.MyCommand.Definition)
if ($ScriptRoot -eq '') { $ScriptRoot = $PSScriptRoot }

$remoteCacheCounts = @{}
$brandRemoteCache = @{}


function Get-StatusString($repoStatus) {
    
    $results = @{
        Staged = @{
            Modified = 0;
            Deleted = 0;
            Added = 0;
            Renamed = 0;
        };
        Unstaged = @{
            Modified = 0;
            Deleted = 0;
            Renamed = 0;
        };
        Untracked = @{
            Added = ($repoStatus.Untracked | ? { $_.State -eq [LibGit2Sharp.FileStatus]::NewInWorkdir }).Count;
        };
        Conflicted = @{
            ConflictUs = 0;
            ConflictThem = 0;
            Conflict = 0;
        }
    }

    SetStatusCounts-ForRepo $repoStatus.Staged $results.Staged
    SetStatusCounts-ForRepo $repoStatus.Added $results.Staged
    SetStatusCounts-ForRepo $repoStatus.RenamedInIndex $results.Staged
    SetStatusCounts-ForRepo $repoStatus.Removed $results.Staged

    SetStatusCounts-ForRepo $repoStatus.Modified $results.Unstaged
    SetStatusCounts-ForRepo $repoStatus.Missing $results.Unstaged
           
    return $results
}

function SetStatusCounts-ForRepo($fileStateLocation, $resultToPopulate) {
    # Use hashtable lookup for increments instead of a bunch of if statements
    ForEach($stausEntry in $fileStateLocation) {
        if ($stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::ModifiedInWorkdir) -or $stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::ModifiedInIndex)) { $resultToPopulate.Modified++ }
        if ($stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::DeletedFromWorkdir) -or $stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::DeletedFromIndex)) { $resultToPopulate.Deleted++ }
        if ($stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::RenamedInIndex)) { $resultToPopulate.Renamed++ }
        if ($stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::NewInWorkdir) -or $stausEntry.State.HasFlag([LibGit2Sharp.FileStatus]::NewInIndex)) { $resultToPopulate.Added++ }
    }
}

function Get-StatusFor($seed, $status, $color, $showNewFiles, $onlyShowNewFiles) {
    
    $result = (Get-StatusCountFragment $seed   $status.Added        'A' $color)
    $result = (Get-StatusCountFragment $result $status.Renamed      'R' $color)
    $result = (Get-StatusCountFragment $result $status.Deleted      'D' $color)
    $result = (Get-StatusCountFragment $result $status.Modified     'M' $color)
    $result = (Get-StatusCountFragment $result $status.Copied       'C' $color)
    $result = (Get-StatusCountFragment $result $status.ConflictUs   'U' $color)
    $result = (Get-StatusCountFragment $result $status.ConflictThem 'T' $color)
    $result = (Get-StatusCountFragment $result $status.Conflict     'B' $color)
    $result = (Get-StatusCountFragment $result $status.RemoteAhead  $arrows.downArrow $color)
    $result = (Get-StatusCountFragment $result $status.LocalAhead   $arrows.upArrow $color)

    if (-not [string]::IsNullOrWhiteSpace($result) -and $seed.Length -ne $result.Length) {
        $result += ' '
    }
    return $result
}

function Get-StatusCountFragment($seed, $count, $symbol, $color) {
    if ($count -gt 0) {
        return "$seed#white#$count#$color#$symbol"
    }
    return $seed;
}

function Get-StashStatus($result, $repo) {
    $count = ($repo.Stashes | measure).Count;
    return (Get-StatusCountFragment $result $count $arrows.stash Yellow)
}

function Get-FilesStatus($repo) {
    
    $statusOptions = New-Object LibGit2Sharp.StatusOptions -Property  @{ IncludeIgnored = $false };
    $repoStatus = $repo.RetrieveStatus($statusOptions)

    $status = Get-StatusString $repoStatus

    $result = (Get-StatusFor "" $status.Conflicted Yellow)
    $result = (Get-StatusFor $result $status.Staged Green)
    $result = (Get-StatusFor $result $status.Unstaged Magenta)
    $result = (Get-StatusFor $result $status.Untracked Gray)
    $result = (Get-StashStatus $result $repo)
    
    return ' ' + $result
}

# Needs to get actual remote branch name when the local name you're tracking is
# different from the remote branch nbame
function Get-RemoteBranchName($currentBranch, $gitRoot, $remoteName) {


    $head = [System.IO.File]::ReadAllText("$gitRoot\.git\HEAD")
    
    # Branch names can contain paths
    $currentRef = $head.Replace("ref: refs/heads/", "").Replace("/", "\").TrimEnd()

    if ((Test-Path -Path "$gitRoot\.git\logs\refs\heads\$currentRef") -and
        (Test-Path -Path "$gitRoot\.git\logs\refs\remotes\$remoteName\$currentRef")) {
        return $currentRef.Replace("\", "/")
    }
    return ''
}

function Get-ConfigValue($repo, $configKey) { 

    $result = $repo.Config | ? { $_.Key -eq $configKey }

    if ($result -eq $null) {
        return ''
    }
    return $result.Value;
} 

function Get-ParentBranch($gitRoot, $currentBranch, $parentSha) {
    
    # Path will not exist for new repositories
    if ((Test-Path -Path "$gitRoot\.git\logs\refs\heads")) {

      $files=[System.IO.Directory]::GetFiles("$gitRoot\.git\logs\refs\heads")

      for($i = 0; $i -lt $files.Length;$i++){

          $fileName = $files[$i]

          # Real all lines even though we only need the first because sometimes the file is left open
          # git fails temporarily
          $first = [System.IO.File]::ReadAllLines($fileName) | select -First 1

          if ($first.Contains($parentSha)) { continue }

          if ([System.IO.File]::ReadAllText($fileName).Contains($parentSha)) {

              return $fileName.Substring($fileName.LastIndexOf('\') + 1)
          }
      }
    }
    return 'master'
}


function Get-BranchRemote($repo, $currentBranch, $gitRoot) {
    
    $cacheResult = $brandRemoteCache.Item($currentBranch)
    if ($cacheResult -ne $null) { return $cacheResult }

    # get remote name of the current branch, i.e. origin
    $remoteName = Get-ConfigValue $repo "branch.$currentBranch.remote"

    if ($remoteName -eq $null -or $remoteName.Trim() -eq '' -or $remoteName -eq '.') {
        
        # To handle branch names with slashes such as 'features/foo-branch' or 'work/bugs/foo-branch'
        $parentBranchLastIndex = $currentBranch.LastIndexOf('/')
        $parentBranchFolder = ''
        if ($parentBranchLastIndex -gt 0) { $parentBranchFolder = $currentBranch.SubString(0, $parentBranchLastIndex) }
        $newCurrentBranchName = $currentBranch.SubString($currentBranch.LastIndexOf('/') + 1)

        
        $remotes = "$gitRoot\.git\logs\refs\remotes"

        if ((Test-Path $remotes)) {

            $file = [System.IO.Directory]::GetFiles($remotes, $newCurrentBranchName, 'AllDirectories')

            if ($file.Length -gt 0) {
                $fullName = (get-item $file[0]).Directory.FullName
            
                if ($parentBranchLastIndex -gt 0) { $fullName = $fullName.Substring(0, $fullName.Length - $parentBranchFolder.Length -1) }

                $remoteName = $fullName.Substring($fullName.LastIndexOf('\') + 1)
            } else {
                $remoteName = ''
            }
        } else {
            $remoteName = ''
        }
    }
    
    $brandRemoteCache.Add($currentBranch, $remoteName)
    return $remoteName
}

function Get-ParentBranchSha($gitRoot, $currentBranch) {

    $branchPath = "$gitRoot\.git\logs\refs\heads\$currentBranch"
    
    # Path will not exist for new repositories
    if ((Test-Path -Path $branchPath)) {
      $firstLine = [System.IO.File]::ReadAllLines("$gitRoot\.git\logs\refs\heads\$currentBranch") | select -First 1
      return $firstLine.SubString(41, 40)
    }
    return ''
}

function Get-CommitStatus($currentBranch, $gitRoot) {

    $repo = New-Object LibGit2Sharp.Repository($gitRoot)

    $remoteAheadCount = 0
    $localAheadCount = 0
    $remoteBranchName = $null
    $masterBehindAhead = ''

    $remoteName = Get-BranchRemote $repo $currentBranch $gitRoot
    $remoteBranchName = Get-RemoteBranchName $currentBranch $gitRoot $remoteName

    $parentSha = Get-ParentBranchSha $gitRoot $currentBranch
    $parentBranchName = Get-ParentBranch $gitRoot $currentBranch $parentSha
    
    $parentBranchDisplayPrefix = $parentBranchName

    if ($parentBranchName.Length -ge 2) {
        $parentBranchDisplayPrefix = $parentBranchName.Substring(0, 2)
    }

    if ($remoteName -ne '' -and $remoteBranchName -ne '') {

        # Get remote commit count ahead of current branch
        $branchDiff = (CachedExceptCommits $repo "HEAD" "$remoteName/$remoteBranchName").Split("`t")
        
        $localAheadCount = $branchDiff[0]
        $remoteAheadCount = $branchDiff[1]

        $result = ""

        if ($remoteAheadCount -gt 0 -and $localAheadCount -gt 0) {
            $result = " #white#$remoteAheadCount#yellow#$($arrows.downArrow)$($arrows.upArrow)#white#$localAheadCount"

        } else {

            $remoteCounts = @{
                RemoteAhead = $remoteAheadCount;
            }

            $result = Get-StatusFor " " $remoteCounts Green

            $remoteCounts = @{
                LocalAhead = $localAheadCount;
            }

            $result = (Get-StatusFor $result $remoteCounts Magenta).TrimEnd()
        }
        
        $branchDiff = (CachedExceptCommits $repo "$remoteName/$remoteBranchName" "$remoteName/$parentBranchName").Split("`t")
        
        $remoteAheadCount = $branchDiff[1]
        $branchAheadCount = $branchDiff[0]

        if ($remoteAheadCount -gt 0 -and $branchAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$remoteAheadCount #yellow#$($arrows.leftRightArrow) #white#$branchAheadCount "  }
        elseif ($remoteAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$remoteAheadCount #magenta#$($arrows.rightArrow) "}
        elseif ($branchAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$branchAheadCount #green#$($arrows.leftArrow) "}

    } else {
        $branchDiff = (CachedExceptCommits $repo "HEAD" "$remoteName/$parentBranchName").Split("`t")
        
        $branchAheadCount = $branchDiff[0]
        $remoteAheadCount = $branchDiff[1]
  
        if ($remoteAheadCount -gt 0 -and $branchAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$remoteAheadCount #cyan#$($arrows.leftRightArrow) #white#$branchAheadCount "  }
        elseif ($remoteAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$remoteAheadCount #cyan#$($arrows.rightArrow) "}
        elseif ($branchAheadCount -gt 0) { $masterBehindAhead = "$parentBranchDisplayPrefix #white#$branchAheadCount #cyan#$($arrows.leftArrow) "}
    }

    $fileStatus = (Get-FilesStatus $repo).TrimEnd()

    $repo.Dispose();

    return "#darkgray#git:($masterBehindAhead#darkgray#$currentBranch$result#darkgray#)$fileStatus"
}

function CachedExceptCommits($repo, $remoteBranch1, $remoteBranch2, $parentSha) {

    if ($remoteBranch1 -eq $remoteBranch2) { return "0`t0" }

    # If the local version of a remote branch is updated then the cache key changes
    # This would happen after a local branch is pushed to a remote
    $branch1ShaTip = $repo.Branches[$remoteBranch1].Tip.Sha
    $branch2ShaTip = $repo.Branches[$remoteBranch2].Tip.Sha
    
    # New repositories
    if ($branch1ShaTip -eq $null -or $branch2ShaTip -eq $null) { return "0`t0" }

    $cachedResults = $remoteCacheCounts[($branch1ShaTip + $branch2ShaTip)];

    if ($cachedResults -eq $null) {
        $count = ExceptCommits $repo $remoteBranch1 $remoteBranch2 $parentSha

        $cachedResults = $remoteCacheCounts[($branch1ShaTip + $branch2ShaTip)] = $count
    }

    return $cachedResults
}

function ExceptCommits($repo, $leftBranch, $rightBranch, $parentSha) {

    $count = "0`t0";

    $null = .{
        $rightBranch = $rightBranch.TrimStart('/')
        $leftBranch = $leftBranch.TrimStart('/')
        
        $rightCommits = $repo.Branches[$rightBranch].Commits
        $leftCommits = $repo.Branches[$leftBranch].Commits

        try {

            $firstLeft = ($leftCommits | select -First 1)
            $firstRight = ($rightCommits | select -First 1)

            if ($firstLeft -eq $firstRight) { return 0 };

        } catch {

            return 0; # Exception will be thown in new repositories with no commits
        }

        $count = (git rev-list --left-right --count "$leftBranch...$rightBranch")
    }

    $count
}

# Does not raise an error when outside of a git repo
function Test-GitRepo($location) {
    
    $directoryInfo = $location;

    if ($location -is [System.Management.Automation.PathInfo]) {
        if ($location.Provider.Name -eq 'FileSystem' -and (-not $location.ProviderPath.StartsWith('\\'))) {
            $directoryInfo = ([System.IO.DirectoryInfo]$location.Path)
        }
    }
    
    $actualGitLocation = [LibGit2Sharp.Repository]::Discover($location)

    if ($actualGitLocation -eq $null) { return }
    
     (New-Object System.IO.FileInfo $actualGitLocation).Directory.Parent.FullName
}

function TimeToUpdate($lastUpdatePath) {

    if ((Test-Path $lastUpdatePath)){
        return (Get-Date).Subtract((Get-Item $lastUpdatePath).LastWriteTime).TotalMinutes -gt 5
    }
    else {
        return $true
    }

    return $false
}

function Begin-SilentFetch($gitRepoPath) {

    $lastUpdatePath = $gitRepoPath + '\.git\lastupdatetime'

    if (TimeToUpdate $lastUpdatePath) {
        echo $null > $lastUpdatePath
        Remove-Job -Name 'gitfetch' -Force -ErrorAction SilentlyContinue

        $remoteCacheCounts.Clear()

        Start-Job -Name 'gitfetch' -ArgumentList $gitRepoPath, $lastUpdatePath -ScriptBlock { param($gitRepoPath, $lastUpdatePath)
            git -C $gitRepoPath fetch --quiet
        }
    }
}

function Show-PsRadar($gitRoot, $currentPath) {

    if($gitRoot -ne $null) {
               
        #Get current branch name
        $currentBranch = git symbolic-ref --short HEAD

        if ($currentBranch -ne $NULL) {
            
            $commitStatus = Get-CommitStatus $currentBranch $gitRoot
            
            $repoName = ($gitRoot.Substring($gitRoot.LastIndexOf('\') + 1) + $currentPath.Substring($gitRoot.Length)).Replace('\', '/')

            Write-Host "$($arrows.rightArrow) " -NoNewline -ForegroundColor Green
            Write-Host "$repoName/ " -NoNewline -ForegroundColor DarkCyan
            Write-Chost "$commitStatus"

            Begin-SilentFetch $gitRepoPath

            return $true
        }
    }

    return $false
}

# Load external functions
Get-ChildItem -Path (Join-Path -Path $ScriptRoot -ChildPath 'Functions' -Resolve) -Filter '*.ps1' |
            ForEach-Object { . $_.FullName }

Set-FirstTimeUserPrefs

Set-ArrowCharacters

Load-LibGit2Sharp $ScriptRoot

Export-ModuleMember -Function '' -WarningAction SilentlyContinue -WarningVariable $null

# Get the existing prompt function
if ($Script:originalPrompt -eq $null) {
    $Script:originalPrompt = (Get-Item function:prompt).ScriptBlock
}

function global:prompt {

    $currentLocation = Get-Location
    $currentPath = $currentLocation.ProviderPath
    $gitRepoPath = Test-GitRepo $currentLocation

    if ($gitRepoPath -ne $null) {

      if (Get-Command "git.exe" -ErrorAction SilentlyContinue) {

          # Change the prompt as soon as we enter a git repository
          if ((Show-PsRadar $gitRepoPath $currentPath)) {
              return "> "
          }
      } else {
          Write-Host "Git-PsRadar will not work unless git.exe is in your path" -ForegroundColor Red
      }
    }
    Invoke-Command $Script:originalPrompt
}