Git.ps1

# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

function Initialize-GitConfig
{
    <#
    .SYNOPSIS
        Configure git before the first use; assigns name and
        email for the current user and sets up some useful defaults
    #>


    [CmdletBinding()]
    param
    (
        [switch] $Force
    )

    $gitName = git config --global user.name
    if( $gitName -and (-not $Force) )
    {
        Write-Warning "Looks like git is already configured. If you want to overwrite git config settings anyway, use -Force switch."
        return
    }

    # Git name and email (required)
    if( $env:USERDOMAIN -eq "Redmond" )
    {
        # Figure out name of the current user from Active Directory
        $ntAccount = New-Object Security.Principal.NTAccount($env:USERDOMAIN, $env:USERNAME)
        $sid = $ntAccount.Translate([Security.Principal.SecurityIdentifier])
        $ldap = [adsi] "LDAP://<SID=$sid>"

        git config --global user.name $ldap.cn
        git config --global user.email "$ENV:USERNAME@microsoft.com"
    }
    else
    {
        $name = Read-Host "User name"
        git config --global user.name $name

        $email = Read-Host "User email"
        git config --global user.email $email
    }
    Write-Output "Git user name and email are configured"

    git config --global --replace-all color.grep auto
    git config --global --replace-all color.grep.filename "green"
    git config --global --replace-all color.grep.linenumber "cyan"
    git config --global --replace-all color.grep.match "magenta"
    git config --global --replace-all color.grep.separator "black"
    git config --global --replace-all grep.lineNumber true
    git config --global --replace-all grep.extendedRegexp true

    git config --global --replace-all color.diff.meta "yellow"
    git config --global --replace-all color.diff.frag "cyan"
    git config --global --replace-all color.diff.func "cyan bold"
    git config --global --replace-all color.diff.commit "yellow bold"
    Write-Output "Git defaults are configured"

    # Aliases for the most used commands
    git config --global alias.co checkout
    git config --global alias.ci commit
    git config --global alias.st status
    git config --global alias.br branch
    git config --global alias.lg "log --graph --pretty=format:'%C(reset)%C(yellow)%h%C(reset) -%C(bold yellow)%d%C(reset) %s %C(green)(%cr) %C(cyan)<%an>%C(reset)' --abbrev-commit --date=relative -n 10"
    git config --global alias.gr "grep --break --heading --line-number -iIE"
    Write-Output "Git aliases are configured"
}

function Open-GitExtensions
{
    <#
    .SYNOPSIS
        Open GitExtensions GUI frontend
        By default the browse window in the current folder would be opened
 
    .PARAMETER Args
        Any arguments that should be passed to the git extensions
 
    .PARAMETER NewEnvironment
        Use new environment for the process.
 
        This is a workaround for CoreXT that redefines the available dot net runtimes
        and this messes up with the .NET 8 runtime lookup done by the latest GitExtensions
 
    .EXAMPLE
        gite commit
 
        Open git extension commit dialog for the repo in the current folder
    #>


    param
    (
        [Parameter( Mandatory = $false )]
        [string[]] $args,
        [switch] $NewEnvironment
    )

    if( -not (Get-Command GitExtensions.exe -ea Ignore) )
    {
        throw "GitExtensions.exe must be discoverable via PATH environment variable"
    }

    $param = $args
    if( -not $param ) { $param = @("browse") }

    if( $NewEnvironment )
    {
        pwsh -nop -c "Start-Process GitExtensions.exe -UseNewEnvironment -WorkingDirectory $pwd -ArgumentList $($param -join ' ')"
    }
    else
    {
        & GitExtensions.exe $param
    }
}

function Get-CommitAuthorName( [string] $commit )
{
    <#
    .SYNOPSIS
        Get author name from a git commit
    #>


    git log -1 --pretty=format:'%aN' $commit
}

function Get-CommitAuthorEmail( [string] $commit )
{
    <#
    .SYNOPSIS
        Get author email from a git commit
    #>


    git log -1 --pretty=format:'%aE' $commit
}

function Get-CommitAuthorDate( [string] $commit )
{
    <#
    .SYNOPSIS
        Get author commit date from a git commit
    #>


    git log -1 --pretty=format:'%ai' $commit
}

function Get-CommitMessage( [string] $commit )
{
    <#
    .SYNOPSIS
        Get commit message from a git commit
    #>


    git log -1 --pretty=format:'%B' $commit
}

function Undo-GitCommit
{
    <#
    .SYNOPSIS
        Revert the last git commit but keep all changes staged
 
    .DESCRIPTION
        Performs 'git reset --soft HEAD~1' which undoes the last commit
        while preserving all changes in the staging area (index).
        This is useful when you want to amend, restructure, or redo
        your last commit without losing any work.
 
    .EXAMPLE
        Undo-GitCommit
 
        Reverts the last commit, leaving changes staged and ready to recommit.
    #>


    [CmdletBinding()]
    param()

    $lastCommit = git log -1 --pretty=format:'%h %s' 2>&1
    if( $LASTEXITCODE -ne 0 )
    {
        Write-Error "No git repository found or no commits exist."
        return
    }

    git reset --soft HEAD~1

    if( $LASTEXITCODE -eq 0 )
    {
        Write-Output "Undid commit: $lastCommit"
        Write-Output "Changes are still staged."
    }
    else
    {
        Write-Error "Failed to undo the last commit."
    }
}

function Reset-GitWorkingTree
{
    <#
    .SYNOPSIS
        Discard all uncommitted changes in the working tree and staging area
 
    .DESCRIPTION
        Performs 'git checkout -- .' to discard modified tracked files and
        'git clean -fd' to remove untracked files and directories.
        This restores the working tree to match the last commit exactly.
 
    .PARAMETER IncludeUntracked
        Also remove untracked files and directories. Default is $true.
 
    .EXAMPLE
        Reset-GitWorkingTree
 
        Discards all uncommitted changes including untracked files.
 
    .EXAMPLE
        Reset-GitWorkingTree -IncludeUntracked:$false
 
        Discards only tracked file changes, keeping untracked files.
    #>


    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [switch] $IncludeUntracked = $true
    )

    $status = git status --porcelain 2>&1
    if( $LASTEXITCODE -ne 0 )
    {
        Write-Error "No git repository found."
        return
    }

    if( -not $status )
    {
        Write-Output "Working tree is already clean."
        return
    }

    if( $PSCmdlet.ShouldProcess("working tree", "Discard all uncommitted changes") )
    {
        git reset HEAD -- . 2>&1 | Out-Null
        git checkout -- .

        if( $IncludeUntracked )
        {
            git clean -fd
        }

        if( $LASTEXITCODE -eq 0 )
        {
            Write-Output "All uncommitted changes have been discarded."
        }
        else
        {
            Write-Error "Failed to reset the working tree."
        }
    }
}