functions/Get-GitDiff.ps1

function Get-GitDiff
{
    <#
    .SYNOPSIS
    Gets the diff between a range of commits.
 
    .DESCRIPTION
    Gets the diff between a range of commits.
    If only a single 'to' commit is specified, the diff will be from its immediate parent.
    If only a single 'from' commit is specified, the diff will be to the HEAD commit.
    One [GitCommitDiff] object is returned for each file covered by the diff.
    [GitCommitDiff] objects can be displayed in readable format using Format-GitDiff.
 
    .PARAMETER Repo
    The name of a git repository, or the path or a substring of the path of a repository directory or any of its subdirectories or files.
    If the Repo parameter is omitted, the current repository will be used if currently inside a repository; otherwise, nothing is returned.
    For examples of using the Repo parameter, refer to the help text for Get-GitRepo.
 
    .PARAMETER SHA1HashFrom
    The SHA1 hash of (or a reference to) a commit in the current repository. If omitted, the parent of the SHA1HashTo commit is used.
 
    .PARAMETER SHA1HashTo
    The SHA1 hash of (or a reference to) a commit in the current repository. If omitted, the HEAD commit is used.
 
    .PARAMETER InputObject
    GitCommit object from e.g. Get-GitLog.
 
    .EXAMPLE
    ## Call from outside a repository without parameters ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Get-GitDiff
 
    # Nothing was returned because a Repo was not provided.
 
    .EXAMPLE
    ## Call from outside a repository for non-existent repository ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> $Powdrgit.ShowWarnings = $true # to ensure warnings are visible
    PS C:\> Get-GitDiff -Repo NonExistentRepo
    WARNING: [Get-GitDiff]Repository 'NonExistentRepo' not found. Check the repository directory exists and has been added to the $Powdrgit.Path module variable.
 
    .EXAMPLE
    ## Call from outside a repository with Repo parameter ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> $diff = Get-GitDiff -Repo Project2
    PS C:\> $diff.Summary; $diff.File | Format-Table
 
    1 file changed, 1 insertion(+)
 
    Action Path PathNew Similarity New Old DiffLine
    ------ ---- ------- ---------- --- --- --------
    Modify Jack.txt 1 0 { Little Jack Horner, +Sat in the corner}
 
    # When neither SHA1HashFrom or SHA1HashTo are specified, the diff of the HEAD commit is returned.
 
    .EXAMPLE
    ## Call from inside a repository with the same commit for SHA1HashFrom and SHA1HashTo parameters ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Set-GitBranch -Repo Project2 -BranchName main -SetLocation # move to the repository directory and checkout the main branch
    PS C:\PowdrgitExamples\Project2> $commitHash = Get-GitLog -NoMerges | Select-Object -First 1 -ExpandProperty SHA1Hash # pick a commit from the log
    PS C:\PowdrgitExamples\Project2> Get-GitDiff -SHA1HashFrom $commitHash -SHA1HashTo $commitHash
 
    # Nothing was returned because there are no differences when comparing a commit against itself.
 
    .EXAMPLE
    ## Call from inside a repository with only the SHA1HashFrom parameter ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Set-GitBranch -Repo Project2 -BranchName main -SetLocation # move to the repository directory and checkout the main branch
    PS C:\PowdrgitExamples\Project2> $commitHash = Get-GitLog -NoMerges | Where-Object Subject -eq "Update Mary's bio" | Select-Object -ExpandProperty SHA1Hash # pick a commit from the log
    PS C:\PowdrgitExamples\Project2> $diff = Get-GitDiff -SHA1HashFrom $commitHash
    PS C:\PowdrgitExamples\Project2> $diff.Summary; $diff.File | Format-Table
 
    2 files changed, 2 insertions(+), 2 deletions(-)
 
    Action Path PathNew Similarity New Old DiffLine
    ------ ---- ------- ---------- --- --- --------
    Add Jack.txt 2 0 {+Little Jack Horner, +Sat in the corner}
    Delete Mary.txt 0 2 {-Mary had a little lamb, -It's fleece was white as snow}
 
    # When only SHA1HashFrom is specified, the HEAD commit is used as the SHA1HashTo commit.
 
    .EXAMPLE
    ## Call from inside a repository with only the SHA1HashTo parameter ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Set-GitBranch -Repo Project2 -BranchName main -SetLocation # move to the repository directory and checkout the main branch
    PS C:\PowdrgitExamples\Project2> $commitHash = Get-GitLog -NoMerges | Where-Object Subject -eq "Update Mary's bio" | Select-Object -ExpandProperty SHA1Hash # pick a commit from the log
    PS C:\PowdrgitExamples\Project2> $diff = Get-GitDiff -SHA1HashTo $commitHash
    PS C:\PowdrgitExamples\Project2> $diff.Summary; $diff.File | Format-Table
 
    1 file changed, 1 insertion(+)
 
    Action Path PathNew Similarity New Old DiffLine
    ------ ---- ------- ---------- --- --- --------
    Modify Mary.txt 1 0 { Mary had a little lamb, +It's fleece was white as snow}
 
    # When only SHA1HashTo is specified, the parent of the SHA1HashTocommit is used as the SHA1HashFrom commit.
 
    .EXAMPLE
    ## Call from inside a repository with both the SHA1HashTo and SHA1HashFrom parameters ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Set-GitBranch -Repo Project2 -BranchName main -SetLocation # move to the repository directory and checkout the main branch
    PS C:\PowdrgitExamples\Project2> $commitHashFrom = Get-GitLog -NoMerges | Where-Object Subject -eq 'Initial commit' | Select-Object -ExpandProperty SHA1Hash # pick a commit from the log
    PS C:\PowdrgitExamples\Project2> $commitHashTo = Get-GitLog -NoMerges | Where-Object Subject -eq "Replace Mary's bio with Jack's" | Select-Object -ExpandProperty SHA1Hash # pick a commit from the log
    PS C:\PowdrgitExamples\Project2> $diff = Get-GitDiff -SHA1HashFrom $commitHashFrom -SHA1HashTo $commitHashTo
    PS C:\PowdrgitExamples\Project2> $diff.Summary; $diff.File | Format-Table
 
    1 file changed, 1 insertion(+)
 
    Action Path PathNew Similarity New Old DiffLine
    ------ ---- ------- ---------- --- --- --------
    Add Jack.txt 1 0 {+Little Jack Horner}
 
    # When SHA1HashFrom and SHA1HashTo are specified, the result shows the net diff for the range of commits.
 
    .EXAMPLE
    ## Pipe results from Get-GitLog ##
 
    PS C:\> $Powdrgit.Path = 'C:\PowdrgitExamples\Project2' # to ensure the repository paths are defined
    PS C:\> Set-GitBranch -Repo Project2 -BranchName main -SetLocation # move to the repository directory and checkout the main branch
    PS C:\PowdrgitExamples\Project2> $diffs = Get-GitLog -NoMerges | Where-Object { $_.ParentHashes } | Get-GitDiff
    PS C:\PowdrgitExamples\Project2> $diffs.Summary; $diffs.File | Format-Table
 
    1 file changed, 1 insertion(+)
    2 files changed, 1 insertion(+), 2 deletions(-)
    1 file changed, 1 insertion(+)
    1 file changed, 1 insertion(+)
 
    Action Path PathNew Similarity New Old DiffLine
    ------ ---- ------- ---------- --- --- --------
    Modify Jack.txt 1 0 { Little Jack Horner, +Sat in the corner}
    Add Jack.txt 1 0 {+Little Jack Horner}
    Delete Mary.txt 0 2 {-Mary had a little lamb, -It's fleece was white as snow}
    Modify Mary.txt 1 0 { Mary had a little lamb, +It's fleece was white as snow}
    Add Mary.txt 1 0 {+Mary had a little lamb}
 
    # When piping commits from Get-GitLog, a diff is created for each commit (from its parent)
 
    .INPUTS
    [System.String[]]
    Accepts string objects via the SHA1Hash parameter. The output of Get-GitLog can be piped into Get-GitCommit.
 
    .OUTPUTS
    [GitCommit]
    Returns git diff output.
 
    .NOTES
    Author : nmbell
 
    .LINK
    Get-GitCommit
    .LINK
    Format-GitDiff
    .LINK
    Get-GitCommitFile
    .LINK
    Get-GitFileHistory
    .LINK
    Get-GitLog
    .LINK
    Get-GitRepo
    .LINK
    about_powdrgit
    .LINK
    https://github.com/nmbell/powdrgit/blob/main/help/about_powdrgit.md
    #>


    # Function alias
    [Alias('ggd')]

    # Use cmdlet binding
    [CmdletBinding(
      DefaultParameterSetName = 'Hash'
    , HelpURI                 = 'https://github.com/nmbell/powdrgit/blob/main/help/Get-GitDiff.md'
    )]

    # Declare output type
    [OutputType('System.String[]')]

    # Declare parameters
    Param(

        [Parameter(
          Mandatory                       = $false
        , Position                        = 0
        , ValueFromPipeline               = $false
        , ValueFromPipelineByPropertyName = $true
        , ParameterSetName                = 'Hash'
        )]
    # [ArgumentCompleter()]
        [Alias('RepoName','RepoPath')]
        [String[]]
        $Repo

    ,    [Parameter(
          Mandatory                       = $false
        , Position                        = 1
        , ValueFromPipeline               = $false
        , ValueFromPipelineByPropertyName = $true
        , ParameterSetName                = 'Hash'
        )]
        [String]
        $SHA1HashFrom

    ,    [Parameter(
          Mandatory                       = $false
        , Position                        = 2
        , ValueFromPipeline               = $false
        , ValueFromPipelineByPropertyName = $true
        , ParameterSetName                = 'Hash'
        )]
        [String]
        $SHA1HashTo

    ,    [Parameter(
          Mandatory                       = $false
        , Position                        = 0
        , ValueFromPipeline               = $true
        , ValueFromPipelineByPropertyName = $false
        , ParameterSetName                = 'InputObject'
        )]
        [GitCommit]
        $InputObject

    )

    BEGIN
    {
        $bk = 'B'

        # Common BEGIN:
        Set-StrictMode -Version 3.0
        $thisFunctionName = $MyInvocation.MyCommand
        $start            = Get-Date
        $indent           = ($Powdrgit.DebugIndentChar[0]+' ')*($PowdrgitCallDepth++)
        $PSDefaultParameterValues += @{ '*:Verbose' = $(If ($DebugPreference -notin 'Ignore','SilentlyContinue') { $DebugPreference } Else { $VerbosePreference }) } # turn on Verbose with Debug
        $warn             = $Powdrgit.ShowWarnings -and !($PSBoundParameters.ContainsKey('WarningAction') -and $PSBoundParameters.WarningAction -eq 'Ignore') # because -WarningAction:Ignore is not implemented correctly
        Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Start: $($start.ToString('yyyy-MM-dd HH:mm:ss.fff'))"

        # Function BEGIN:
        Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Storing current location"
        $startLocation = $PWD.Path

        $gitCommandTemplate = 'git diff --find-renames --find-copies --ignore-space-at-eol <option> <SHA1HashFrom> <SHA1HashTo>'
        # --output-indicator-new=<char>
        # --output-indicator-old=<char>
        # --output-indicator-context=<char>
        # --src-prefix=<prefix>
        # --dst-prefix=<prefix>
        # --find-copies-harder
    }

    PROCESS
    {
        $bk = 'P'

        # Set commits if not provided
        If ($InputObject)
        {
            $SHA1HashTo   = $InputObject.SHA1Hash
            $SHA1HashFrom = "$SHA1HashTo^"
        }
        Else
        {
            If (!$SHA1HashTo  ) { $SHA1HashTo   = 'HEAD'         }
            If (!$SHA1HashFrom) { $SHA1HashFrom = "$SHA1HashTo^" }
        }
        Write-Debug " [$thisFunctionName]`$SHA1HashTo = $SHA1HashTo"
        Write-Debug " [$thisFunctionName]`$SHA1HashFrom = $SHA1HashFrom"

        # Find the repository name from current location
        If (!$PSBoundParameters.ContainsKey('Repo')) { $Repo = Get-GitRepo -Current | Select-Object -ExpandProperty RepoPath }

        # Get the repository info
        $validRepos = Get-ValidRepo -Repo $Repo

        # Get the commits
        ForEach ($validRepo in $validRepos)
        {
            # Go to the repository and get the repository info
            Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Moving to the repository directory: $($validRepo.RepoPath)"
            Set-GitRepo -Repo $validRepo.RepoPath -WarningAction Ignore

            # Validate parameters
            ForEach ($SHA1Hash in $SHA1HashFrom,$SHA1HashTo)
            {
                $gitCommandRefType = "git cat-file -t $SHA1Hash"
                $refType = Invoke-GitExpression -Command $gitCommandRefType -SuppressGitErrorStream
                If ($refType -notin 'commit')
                {
                    If ($warn) { Write-Warning "[$thisFunctionName]`"$SHA1Hash`" is not a valid commit in repository '$($validRepo.RepoName)'." }
                }
            }

            # Get diff info
            Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Gathering commit diff info"
            $diff         = [GitCommitDiff]::new()
            $gitCommand   = $gitCommandTemplate.Replace('<option>','--shortstat').Replace('<SHA1HashFrom>',$SHA1HashFrom).Replace('<SHA1HashTo>',$SHA1HashTo)
            $diffOutput   = Invoke-GitExpression -Command $gitCommand -SuppressGitErrorStream
            If ($diffOutput)
            {
                $diff.Summary = $diffOutput.Trim()
                $gitCommand   = $gitCommandTemplate.Replace('<option>','--name-status').Replace('<SHA1HashFrom>',$SHA1HashFrom).Replace('<SHA1HashTo>',$SHA1HashTo)
                $nameStatDiff = Invoke-GitExpression -Command $gitCommand -SuppressGitErrorStream
                ForEach ($line in $nameStatDiff)
                {
                    $diffFile           = [GitCommitDiffFile]::new()
                    $type,$file1,$file2 = $line-split "`t"
                    $diffFile.Path      = $file1
                    $diffFile.PathNew   = $file2
                    $diffFile.New       = $null
                    $diffFile.Old       = $null
                    If ($type -eq   'A' ) { $diffFile.Action = 'Add'                                                 }
                    If ($type -like 'C*') { $diffFile.Action = 'Copy'    ; $diffFile.Similarity = $type.Substring(1) }
                    If ($type -eq   'D' ) { $diffFile.Action = 'Delete'                                              }
                    If ($type -eq   'M' ) { $diffFile.Action = 'Modify'                                              }
                    If ($type -like 'R*') { $diffFile.Action = 'Rename'  ; $diffFile.Similarity = $type.Substring(1) }
                    If ($type -eq   'T' ) { $diffFile.Action = 'Type'                                                }
                    If ($type -eq   'U' ) { $diffFile.Action = 'Unmerged'                                            }
                    If ($type -eq   'X' ) { $diffFile.Action = 'Unknown'                                             }
                    $diff.File += $diffFile
                }

                $gitCommand   = $gitCommandTemplate.Replace('<option>','--numstat').Replace('<SHA1HashFrom>',$SHA1HashFrom).Replace('<SHA1HashTo>',$SHA1HashTo)
                $numStatsDiff = Invoke-GitExpression -Command $gitCommand -SuppressGitErrorStream
                $f = 0
                ForEach ($line in $numStatsDiff)
                {
                    $diff.File[$f].New,$diff.File[$f].Old,$file = $line -split "`t"
                    $f++
                }

                $gitCommand   = $gitCommandTemplate.Replace('<option>','--unified=9').Replace('<SHA1HashFrom>',$SHA1HashFrom).Replace('<SHA1HashTo>',$SHA1HashTo)
                $unifiedDiff  = Invoke-GitExpression -Command $gitCommand -SuppressGitErrorStream

                $isHeader = $f = -1
                ForEach ($line in $unifiedDiff)
                {
                    If ($line -like 'diff --git*')
                    {
                        $lineRange  = $null
                        $isHeader = 1
                        $f++
                    }
                    If ($line -like '@@ *')
                    {
                        $isHeader    = 0
                        $lineRange   = ($line -split ' ')[1,2]
                        $range1      = ($lineRange[0].Replace('-','')) -split ','
                        $range2      = ($lineRange[1].Replace('+','')) -split ','
                        $rangeBefore = [Int]($range1)[0]
                        $rangeAfter  = [Int]($range2)[0]
                        Continue
                    }
                    If ($isHeader) { Continue }
                    If ($lineRange)
                    {
                        $diffLine = [GitCommitDiffLine]::new()
                        If ($line -like '+*')
                        {
                            $lineChange        = '+'
                            $rangeBeforeString = ''
                            $rangeAfterString  = $rangeAfter.ToString()
                            $rangeAfter++
                        }
                        ElseIf ($line -like '-*')
                        {
                            $lineChange        = '-'
                            $rangeBeforeString = $rangeBefore.ToString()
                            $rangeAfterString  = ''
                            $rangeBefore++
                        }
                        ElseIf ($line -eq '\ No newline at end of file')
                        {
                            $lineChange        = ' '
                            $rangeBeforeString = ''
                            $rangeAfterString  = ''
                            $line              = ' [No newline at end of file]'
                        }
                        Else
                        {
                            $lineChange        = ' '
                            $rangeBeforeString = $rangeBefore.ToString()
                            $rangeAfterString  = $rangeAfter.ToString()
                            $rangeBefore++
                            $rangeAfter++
                        }
                        $line                   = $line.Substring(1)
                        $diffLine.LineNumBefore = $rangeBeforeString
                        $diffLine.LineNumAfter  = $rangeAfterString
                        $diffLine.LineChange    = $lineChange
                        $diffLine.LineText      = $line
                        $diff.File[$f].DiffLine += $diffLine
                    }
                }

                # Output
                $diff
            }
        }
    }

    END
    {
        $bk = 'E'

        # Function END:
        Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Setting location to original directory"
        Set-Location -Path $startLocation

        # Common END:
        $end      = Get-Date
        $duration = New-TimeSpan -Start $start -End $end
        Write-Debug " $(ts)$indent[$thisFunctionName][$bk]Finish: $($end.ToString('yyyy-MM-dd HH:mm:ss.fff')) ($($duration.ToString('d\d\ hh\:mm\:ss\.fff')))"
        $PowdrgitCallDepth--
    }
}