TM-GitUtility.psm1

using namespace System.IO
using namespace System.Collections.Generic

# Write verbose messages on import
if ((Get-PSCallStack)[1].Arguments -imatch 'Verbose=True') { $PSDefaultParameterValues['*:Verbose'] = $true }

# Ensure we're using the primary write commands from the Microsoft.PowerShell.Utility module.
Set-Alias -Name 'Write-Progress'    -Value 'Microsoft.PowerShell.Utility\Write-Progress'    -Scope Script
Set-Alias -Name 'Write-Debug'       -Value 'Microsoft.PowerShell.Utility\Write-Debug'       -Scope Script
Set-Alias -Name 'Write-Verbose'     -Value 'Microsoft.PowerShell.Utility\Write-Verbose'     -Scope Script
Set-Alias -Name 'Write-Host'        -Value 'Microsoft.PowerShell.Utility\Write-Host'        -Scope Script
Set-Alias -Name 'Write-Information' -Value 'Microsoft.PowerShell.Utility\Write-Information' -Scope Script
Set-Alias -Name 'Write-Warning'     -Value 'Microsoft.PowerShell.Utility\Write-Warning'     -Scope Script
Set-Alias -Name 'Write-Error'       -Value 'Microsoft.PowerShell.Utility\Write-Error'       -Scope Script

if ((Test-ApplicationExistsInPath -ApplicationName 'git') -eq $false) {
    Write-Verbose 'git does not exist in the Path. Skipping the import of git ProfileUtility commands.'
    # Do not export any module commands.
    Export-ModuleMember
    return
}
# https://github.com/PowerShell/PowerShell/issues/17730#issuecomment-1190678484
$ExportedMembers = [List[string]]::new()


class GitStatusInfo {
<#
    .SYNOPSIS
    This class is used to encapsulate the current status information of a Git repository.
 
    .DESCRIPTION
    GitStatusInfo contains lists of files that have been updated, unchanged, stage, untracked, or ignored.
    It provides a static method, GetCurrentStatusInfo, which checks the status of the repository using the
    'git status' command and fills these lists accordingly.
 
    This class helps in managing and visualizing the status information of a Git repository.
#>

    $Updated        = [List[FileInfo]]::new()
    $Deleted        = [List[FileInfo]]::new()
    $AddedStaged    = [List[FileInfo]]::new()
    $ModifiedStaged = [List[FileInfo]]::new()
    $UnTracked      = [List[FileInfo]]::new()
    $Ignored        = [List[FileInfo]]::new()
    $Unchanged      = [List[FileInfo]]::new()

    [GitStatusInfo] static GetCurrentStatusInfo([string]$gitPath = [string]::Empty) {
        function Get-FilePaths {
            [OutputType([void])]
            param(
                [Parameter(Mandatory)]
                [string]$Path,

                [Parameter(Mandatory)]
                [AllowEmptyCollection()]
                [List[FileInfo]]$Collection,

                [Parameter(Mandatory)]
                [string]$Type
            )

            if ([Directory]::Exists($Path)) {
                foreach ($File in (Get-ChildItem -Path $Path -Recurse -File)) {
                    $Collection.Add($File)
                }
            } elseif ([File]::Exists($Path)) {
                $Collection.Add([FileInfo]::New($Path))
            } else {
                Write-Warning "$Type Path '$Path' does not exist."
            }
        }

        $CurrentStatus = [GitStatusInfo]::New()

        if ([string]::IsNullOrWhiteSpace($GitPath)) { $GitPath = Get-GitPath }

        if ([string]::IsNullOrWhiteSpace($GitPath)) {
            Write-Warning 'The path heirarchy does not contain a git directory.'
            return $CurrentStatus
        }

        $GitPath = (Resolve-Path -Path $GitPath -ErrorAction Stop).Path
        Push-Location $GitPath

        try {
            foreach ($line in (git status --show-stash --ignored --porcelain)) {
                $StartSymbol = $Line.Substring(0, 2)
                $GitPorcelainPath = $Line.Substring(2).Trim().TrimStart('"').TrimEnd('"')
                $ItemPath = Join-Path -Path $GitPath -ChildPath $GitPorcelainPath
                $Item = Get-Item -Path $ItemPath -Force
                switch ($StartSymbol) {
                    ' M' { $CurrentStatus.Updated.Add($Item) }
                    ' D' { $CurrentStatus.Deleted.Add([FileInfo]::New($ItemPath)) }
                    'A ' { $CurrentStatus.AddedStaged.Add($Item) }
                    'M ' { $CurrentStatus.ModifiedStaged.Add($Item) }
                    '??' { Get-FilePaths -Path $Item.FullName -Collection $CurrentStatus.UnTracked -Type 'UnTracked' }
                    '!!' { Get-FilePaths -Path $Item.FullName -Collection $CurrentStatus.Ignored -Type 'Ignored' }
                    default { Write-Warning "GitStatusInfo Unknown Element: '$StartSymbol'" }
                }
            }

            [string[]]$ChangeElements = @(
                $CurrentStatus.Updated.FullName;
                $CurrentStatus.Deleted.FullName;
                $CurrentStatus.AddedStaged.FullName;
                $CurrentStatus.ModifiedStaged.FullName;
                $CurrentStatus.UnTracked.FullName;
                $CurrentStatus.Ignored.FullName
            )

            foreach ($line in (git ls-files)) {
                $ItemPath = Join-Path -Path $GitPath -ChildPath $line
                if ($ChangeElements -notcontains $ItemPath) {
                    $CurrentStatus.Unchanged.Add((Get-Item -Path $ItemPath -Force))
                }
            }
        } finally {
            Pop-Location
        }

        return $CurrentStatus
    }
}


function Get-GitBranch {
<#
    .SYNOPSIS
    Returns the current Git branch with a relevant symbol based on the branch name.
 
    .DESCRIPTION
    The function recursively searches for the .git directory in the current and parent directories and retrieves
    the current Git branch. Symbols are also appended to the string if the branch matches the following patterns
        🚀 for '/master' or '/main'
        🚧 for '/dev'
 
    .PARAMETER Path
    An optional parameter allowing you to specify the location of the git folder to retrieve a branch information for.
#>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $false)]
        [Validation.ValidatePathExists('Folder')]
        [string]$Path = (Get-CurrentPath)
    )

    if ((Get-GitPath -Path $Path) -ne [string]::Empty) {
        # need to do this so the stderr doesn't show up in $error
        $ErrorActionPreferenceOld = $ErrorActionPreference
        $ErrorActionPreference = 'Ignore'
        $branch = git rev-parse --abbrev-ref --symbolic-full-name '@{u}'
        $ErrorActionPreference = $ErrorActionPreferenceOld
        # handle case where branch is local
        if ($lastexitcode -ne 0 -or $null -eq $branch) {
            $branch = git rev-parse --abbrev-ref HEAD
        }
        $branchSymbol = if (($branch -imatch '/master') -or ($branch -imatch '/main')) {
            '🚀'
        } elseif ($branch -imatch '/dev') {
            '🚧'
        }
        return "[$branch$branchSymbol] "
    }
    return [string]::Empty
}
$ExportedMembers.Add('Get-GitBranch')


function Get-GitPath {
<#
    .SYNOPSIS
    Retrieves the path of the current Git repository if it exists in the current path hierarchy.
 
    .DESCRIPTION
    This function looks for the .git directory starting from the specified path and continuing up the directory
    tree.
 
    .PARAMETER Path
    The path to begin the search for the git repository in. By default this will look in the current working directory.
 
    .OUTPUTS
    Returns the path of the repository where the .git directory is located as a string.
    If no .git directory is found, it returns an empty string.
#>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $false)]
        [Validation.ValidatePathExists('Folder')]
        [string]$Path = (Get-CurrentPath)
    )

    $IteratePath = "$Path"

    while ([string]::IsNullOrWhiteSpace($IteratePath) -eq $false) {
        if ([Directory]::Exists((Join-Path -Path $IteratePath -ChildPath '.git'))) {
            return $IteratePath
        }
        $IteratePath = [Path]::GetDirectoryName($IteratePath)
    }
    return [string]::Empty
}
$ExportedMembers.Add('Get-GitPath')


function Get-GitStatusInfo {
<#
    .SYNOPSIS
    Retrieves the git status info for the specified git directory.
#>

    [CmdletBinding()]
    [OutputType([GitStatusInfo])]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Path = [string]::Empty
    )

    [GitStatusInfo]::GetCurrentStatusInfo($Path)
}
$ExportedMembers.Add('Get-GitStatusInfo')


function Redo-GitCommitAsSigned {
<#
    .SYNOPSIS
    Modifies the last git commit to be signed.
 
    .DESCRIPTION
    This function allows you to sign the last commit by amending it with the -S flag.
 
    .EXAMPLE
    Redo-GitCommitAsSigned
    Signs the last commit in the current repository.
#>

    [Alias('Sign-LastGitCommit')]
    [OutputType([Void])]
    param()

    git commit --amend --no-edit -n -S
}
$ExportedMembers.Add('Redo-GitCommitAsSigned')


function Remove-GitTag {
<#
    .SYNOPSIS
    Removes a specified git tag both locally and remotely.
 
    .PARAMETER Tag
    The name of the git tag to remove.
 
    .PARAMETER Remote
    Removes the tag from the origin location in addition to the removing the tag from the local repository.
 
    .EXAMPLE
    # Remove the 'v1.0.0' tag from the local and remote repositories.
    Remove-GitTag -Tag 'v1.0.0' -Remote
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Tag,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeRemote
    )

    git tag -d $Tag
    if ($IncludeRemote) {
        git push --delete origin $Tag
    }
}
$ExportedMembers.Add('Remove-GitTag')


function Set-GitFileAssumeUnchanged {
<#
    .SYNOPSIS
    Tells git to assume a file in the Git repository is unchanged.
 
    .DESCRIPTION
    This function makes Git "assume" the file has not been changed, meaning that Git will ignore any changes to
    that file. The file will not appear in 'git status' and it will not be checked for changes.
    This is useful for files with local changes that are needed, but should not be committed.
 
    .PARAMETER FilePath
    The relative or absolute path to the file to mark as "assume unchanged".
 
    .PARAMETER GitPath
    The relative or absolute path to the file to mark as "assume unchanged".
#>

    [Alias('Ignore-GitFileChanges')]
    [OutputType([Void])]
    param(
        [Parameter(Mandatory)]
        [Validation.ValidatePathExists('File')]
        [string]$FilePath
    )

    $Item = Get-Item -Path $FilePath -Force -ErrorAction Stop
    Push-Location (Get-GitPath -Path $Item.Parent)
    try {
        $StatusInfo = [GitStatusInfo]::GetCurrentStatusInfo()
        if ($StatusInfo.Staged.FullName -contains $Item.FullName) {
            # Reset the item so that it is no longer staged
            git reset HEAD -- $Item.FullName
        }
        git update-index --assume-unchanged $FilePath
    } finally {
        Pop-Location
    }
}
$ExportedMembers.Add('Set-GitFileAssumeUnchanged')


if ($IsLinux) {
    function Set-GitSettings {
    <#
        .SYNOPSIS
        Configures default git settings for a Linux PowerShell environment.
 
        .DESCRIPTION
        This function sets up the default SSH and GPG settings for git within a Linux PowerShell environment.
        It is not designed for use on non-Linux platforms.
 
        .PARAMETER GithubSSHKeyPath
        The file path to the SSH key you want to use for GitHub.
        If no key is provided then the function will use the default id_ed25519 key if it exists or prompts the user
        to select an available key.
 
        .EXAMPLE
        # Configures git settings using the default or user-selected SSH key.
        Set-GitSettings
 
        .EXAMPLE
        # Configures git settings using the specified SSH key at '~/.ssh/my_key'.
        Set-GitSettings -GithubSSHKeyPath '~/.ssh/my_key'
    #>

        [CmdletBinding()]
        [OutputType([Void])]
        param (
            [Parameter(Mandatory = $false)]
            [IO.FileInfo]$GithubSSHKeyPath
        )

        $SSHAgent = 'ssh-agent'
        $SSHAdd = 'ssh-add'
        if (
            (
                (Test-ApplicationExistsInPath -ApplicationName $SSHAgent) -and
                (Test-ApplicationExistsInPath -ApplicationName $SSHAdd)
            ) -ne $true
        ) {
            throw 'Missing required applications. Task requires both ssh-agent and ssh-add.'
        }
        try { & $SSHAgent -k *>&1 | Out-Null } catch { }
        $Output = & $SSHAgent -s

        $Env:SSH_AUTH_SOCK = $Output[0].Split('=')[1].split(';')[0]
        $Env:SSH_AGENT_PID = $Output[1].Split('=')[1].split(';')[0]
        $Env:GPG_TTY = tty

        # Find the right SSH key to use for github.
        $SSH = if (Test-Path $GithubSSHKeyPath -PathType Leaf -ErrorAction Ignore) {
            # If the user profides a path, and it exists, use that.
            $GithubSSHKeyPath
        } elseif (Test-Path '~/.ssh/id_ed25519' -PathType Leaf -ErrorAction Ignore) {
            # If no user provided key exists then try github default recommended.
            (Get-Item '~/.ssh/id_ed25519').FullName
        } else {
            # last resort - ask the user.
            $Keys = Get-ChildItem -Path '~/.ssh/' -File | Where-Object {
                ($_.Extension -ine '.pub') -and
                ($_.name -ine 'known_hosts')
            }
            if ($Keys) {
                $Index = 0
                $KeyList = Foreach ($Key in $Keys) {
                    [PSCustomObject]@{
                        Number = $Index
                        Path   = $Key.FullName
                    }
                    ++$Index
                }
                $PromptAnswer = Read-Host -Prompt (
                    "Enter the number for the ssh key you want to use.`n" +
                    ($KeyList | Format-Table | Out-String)
                )
                $Keys[$PromptAnswer].FullName
            } else {
                Write-Error 'No SSH Keys found.'
            }
        }
        & $SSHAdd $SSH
    }
    $ExportedMembers.Add('Set-GitSettings')
}


Export-ModuleMember -Function $ExportedMembers.ToArray()