Prompt55.psm1

<#
Module built with ModuleForge
     ModuleForge Version: 1.3.1
    ModuleForge psd1 SHA256: 14d6d96df78aa67c9020c6bb97ac5993c1d4fb44488d6dbb3efc62606001cc61
    ModuleForge psm1 SHA256: 2a88fcfae73087886ccade3a4b8090b68d2aca021520a13b2166bab818922c60
    BuildDate: 2026-09-20T14:51:57
#>

function Hide-TerminalPath
{

    <#
        .SYNOPSIS
            [Experimental] Toggles whether the custom Prompt shows your real location or drops the path entirely.

        .DESCRIPTION
            Sometimes you want to copy a command, or screenshot/share a terminal, without also
            handing over your directory structure - usernames, project names, internal paths and
            the like. When enabled, the path is dropped outright rather than replaced with a
            placeholder - you're left with a bare '>' (the normal two-line layout) or no path line
            at all (the Set-PromptMultiLine layout).

            Sets $global:promptParams.Settings.HideTerminalPath (see Initialize-PromptParams). Off
            by default - every new shell starts showing the real path. Like
            $global:thisTerminalName, this does not persist between terminal sessions.

        .PARAMETER Off
            Shows the real path again. Omit to hide it.

        .EXAMPLE
            Hide-TerminalPath

            Drops the path segment of the prompt for the rest of this session.

        .EXAMPLE
            Hide-TerminalPath -Off

            Shows the real path again.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    PARAM(
        #Shows the real path again. Omit to hide it.
        [Parameter()]
        [switch]$Off
    )

    process
    {
        Initialize-PromptParams
        $global:promptParams.Settings.HideTerminalPath = -not $Off.IsPresent
        Write-Verbose "Terminal path is now $(if ($global:promptParams.Settings.HideTerminalPath) { 'hidden' } else { 'shown' })"
    }

}

function Prompt
{

    <#
        .SYNOPSIS
            Custom PowerShell prompt showing location, terminal name, a colour-graded time, last command duration, and git status.

        .DESCRIPTION
            Builds a two-line prompt:
                [terminalName | HH:mm | duration | status | H:historyId][branch|U:n M:n S:n C:+n]
                path>

            [Experimental] Once Set-PromptMultiLine has been called, it spreads across up to four
            lines instead - main segment, git segment (when there is one), the path in its own
            '[...]' (when it isn't hidden - see Hide-TerminalPath), then a bare '>' - see
            Set-PromptMultiLine.

            The terminal name segment only appears once $global:thisTerminalName has been set (see
            Set-TerminalName). The duration/history segment only appears once at least one command
            has run in the session. The git segment only appears when the current directory is
            inside a git repository (detected via a filesystem-only check, so directories outside
            a repo cost essentially nothing) and the git executable is available on PATH.

            The git segment costs one 'git status' process spawn per prompt draw while inside a
            repository (roughly 20-30ms in testing). It's enabled by default; use
            Set-PromptGitStatus -Off to disable it for the session if that cost isn't worth it to
            you, or Set-PromptGitStatus with no parameters to turn it back on.

            [Experimental] The path line normally shows your real location. Once Hide-TerminalPath
            has been called, the path is dropped entirely rather than replaced with a placeholder -
            you're left with a bare '>' (two-line layout) or no path line at all (Set-PromptMultiLine)
            - useful when copying commands or sharing a screenshot without also handing over your
            directory structure. Off by default; call Hide-TerminalPath -Off to show the real path
            again.

            Every colour and threshold mentioned below, plus both toggles above, lives in
            $global:promptParams (see Initialize-PromptParams) and can be customised - either
            directly, or via Set-PromptParam, e.g. Set-PromptParam -Name 'Colors.Untracked'
            -Value @(0, 255, 0).

            When the host supports ANSI virtual terminal sequences:
              - The hour and minute are each colour-graded on a rainbow sweep from blue (start of
                the range) to red (end of the range).
              - The duration is colour-graded from green (fast) to red (60 seconds or slower) on a
                logarithmic scale, so it moves away from green quickly - by ~10 seconds it's
                already amber, not still green - rather than lingering there for most of the
                range the way a linear scale would. Followed by a single-letter status symbol for
                the last command's success/failure: green 'P' (pass) or red 'F' (fail).
              - The branch name is orange for main/master, or one of LightBlue/LightPurple/Green -
                always the same one for a given branch name - for anything else.
              - U: (untracked file count) is red, M: (modified but unstaged file count) is cyan,
                S: (staged change count) is blue, and C: (commits ahead/behind the upstream, e.g.
                +2, -3, +2/-3 if diverged) is yellow. C: is omitted entirely when the branch has no
                upstream configured.
              - [Experimental] The path line's drive (or PSDrive prefix, e.g. 'C:' or 'HKLM:') is
                blue, each path divider ('\' or '/') is purple, and the trailing '>' is cyan (the
                same shade as the M: git segment, for contrast against the path text).
            Hosts that don't support virtual terminal sequences get plain, uncoloured text instead.

        .EXAMPLE
            Prompt

            Returns the prompt string for the current location, command history, and git status.

        .NOTES
            Author: Adrian Andersson

    #>


    #Capture the outcome of the last user command as the very first thing this function does.
    #$? reflects whatever statement most recently ran before Prompt was invoked - any cmdlet call
    #we make first (even Get-History) would overwrite it with that call's own result instead.
    $lastCommandSucceeded = $?

    #Cheap hashtable-key checks - ensures $global:promptParams exists and is fully populated,
    #backfilling anything missing without touching whatever the user has already customised
    Initialize-PromptParams

    $lastCommand = Get-History -Count 1
    $now = Get-Date

    #Some hosts (e.g. output redirected to a file, or a host without VT support) can't render
    #ANSI escape sequences - fall back to plain text rather than printing raw escape codes
    $supportsColor = $Host.UI.SupportsVirtualTerminal

    if ($supportsColor)
    {
        $reset = $PSStyle.Reset
        $timeColors = $global:promptParams.Colors.Time
        $hourColor = Get-PromptGradientColor -Value $now.Hour -Minimum 0 -Maximum 23 -StartHue $timeColors.StartHue -EndHue $timeColors.EndHue
        $minuteColor = Get-PromptGradientColor -Value $now.Minute -Minimum 0 -Maximum 59 -StartHue $timeColors.StartHue -EndHue $timeColors.EndHue
        $timeString = "$hourColor$($now.ToString('HH'))$reset`:$minuteColor$($now.ToString('mm'))$reset"
    }
    else
    {
        $timeString = $now.ToString('HH:mm')
    }

    #[Experimental] Off by default ($global:promptParams.Settings.HideTerminalPath defaults to
    #$false, e.g. a fresh session, means "show the real path") - see Hide-TerminalPath. When
    #hidden, the path is dropped outright (an empty $pathText) rather than replaced with a
    #placeholder - $location below collapses to just the arrow, and the MultiLine layout skips
    #the path line entirely.
    if ($global:promptParams.Settings.HideTerminalPath -eq $true)
    {
        $pathText = ''
    }
    else
    {
        $rawPath = "$($executionContext.SessionState.Path.CurrentLocation)"
        $pathText = if ($supportsColor) { Get-PromptColoredPath -Path $rawPath -DriveColor $global:promptParams.Colors.PathDrive -DividerColor $global:promptParams.Colors.PathDivider } else { $rawPath }
    }

    $arrowColor = $global:promptParams.Colors.PathArrow
    $arrowText = if ($supportsColor) { "$($PSStyle.Foreground.FromRgb($arrowColor[0], $arrowColor[1], $arrowColor[2]))>$reset" } else { '>' }
    $location = "$pathText$arrowText "

    if ($global:thisTerminalName)
    {
        $promptString = "$global:thisTerminalName | $timeString"
    }
    else
    {
        $promptString = $timeString
    }

    $mainSegment = "[$promptString]"

    if ($lastCommand)
    {
        $statusSymbol = if ($lastCommandSucceeded) { 'P' } else { 'F' }
        $durationSeconds = [math]::Round($($lastCommand.EndExecutionTime.Subtract($lastCommand.StartExecutionTime).TotalSeconds), 2)

        if ($supportsColor)
        {
            $durationParams = $global:promptParams.Colors.Duration
            $durationColor = Get-PromptGradientColor -Value $durationSeconds -Minimum $durationParams.Minimum -Maximum $durationParams.Maximum -StartHue $durationParams.StartHue -EndHue $durationParams.EndHue -Logarithmic
            $statusColor = if ($lastCommandSucceeded) { $global:promptParams.Colors.Success } else { $global:promptParams.Colors.Failure }
            $statusText = "$($PSStyle.Foreground.FromRgb($statusColor[0], $statusColor[1], $statusColor[2]))$statusSymbol$reset"
            $durationString = "$durationColor$durationSeconds$reset | $statusText"
        }
        else
        {
            $durationString = "$durationSeconds | $statusSymbol"
        }

        $mainSegment = "[$promptString | $durationString | H:$($lastCommand.Id)]"
    }

    $gitSegment = ''

    #Enabled by default - $global:promptParams.Settings.GitStatusEnabled defaults to $true, only
    #ever $false after an explicit Set-PromptGitStatus -Off. Checked before anything else here so
    #a disabled segment skips even the cheap filesystem check below.
    if ($global:promptParams.Settings.GitStatusEnabled -ne $false)
    {
        #Resolve the git executable once per session (cached on the module's script scope) rather
        #than re-searching PATH on every prompt draw. $false is a deliberate "resolved: not found"
        #sentinel, distinct from $null ("not resolved yet"), so a missing git is only looked for once.
        if ($null -eq $script:promptGitExe)
        {
            $foundGit = Get-Command -Name git -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
            $script:promptGitExe = if ($foundGit) { $foundGit.Source } else { $false }
        }
    }

    if ($script:promptGitExe -and $global:promptParams.Settings.GitStatusEnabled -ne $false)
    {
        #Pure filesystem check - no process spawn - so directories outside a repo (likely the
        #common case) cost almost nothing. Only found repos pay for the git invocation below.
        $repoRoot = Find-GitRepositoryRoot -Path $executionContext.SessionState.Path.CurrentLocation.Path

        if ($repoRoot)
        {
            $statusLines = Get-GitStatusPorcelainText -GitPath $script:promptGitExe -RepositoryRoot $repoRoot
            $gitStatus = $statusLines | ConvertFrom-GitStatusPorcelain

            if ($gitStatus.Branch)
            {
                if ($supportsColor)
                {
                    $gitColors = $global:promptParams.Colors
                    $branchColor = Get-PromptBranchColor -BranchName $gitStatus.Branch -MainColor $gitColors.BranchMain -Palette $gitColors.BranchPalette
                    $branchText = "$branchColor$($gitStatus.Branch)$reset"
                    $untrackedText = "$($PSStyle.Foreground.FromRgb($gitColors.Untracked[0], $gitColors.Untracked[1], $gitColors.Untracked[2]))U:$($gitStatus.UntrackedCount)$reset"
                    $modifiedText = "$($PSStyle.Foreground.FromRgb($gitColors.Modified[0], $gitColors.Modified[1], $gitColors.Modified[2]))M:$($gitStatus.ModifiedCount)$reset"
                    $stagedText = "$($PSStyle.Foreground.FromRgb($gitColors.Staged[0], $gitColors.Staged[1], $gitColors.Staged[2]))S:$($gitStatus.StagedCount)$reset"
                }
                else
                {
                    $branchText = $gitStatus.Branch
                    $untrackedText = "U:$($gitStatus.UntrackedCount)"
                    $modifiedText = "M:$($gitStatus.ModifiedCount)"
                    $stagedText = "S:$($gitStatus.StagedCount)"
                }

                $gitSegment = "[$branchText|$untrackedText $modifiedText $stagedText"

                if ($gitStatus.HasUpstream)
                {
                    $aheadBehind = if ($gitStatus.Ahead -gt 0 -and $gitStatus.Behind -gt 0)
                    {
                        "+$($gitStatus.Ahead)/-$($gitStatus.Behind)"
                    }
                    elseif ($gitStatus.Ahead -gt 0)
                    {
                        "+$($gitStatus.Ahead)"
                    }
                    elseif ($gitStatus.Behind -gt 0)
                    {
                        "-$($gitStatus.Behind)"
                    }
                    else
                    {
                        '0'
                    }

                    $commitText = if ($supportsColor)
                    {
                        $commitColor = $global:promptParams.Colors.Commits
                        "$($PSStyle.Foreground.FromRgb($commitColor[0], $commitColor[1], $commitColor[2]))C:$aheadBehind$reset"
                    }
                    else
                    {
                        "C:$aheadBehind"
                    }

                    $gitSegment = "$gitSegment $commitText"
                }

                $gitSegment = "$gitSegment]"
            }
        }
    }

    #[Experimental] Off by default - see Set-PromptMultiLine
    if ($global:promptParams.Settings.MultiLine -eq $true)
    {
        #Array subexpression instead of building $lines up with += - each 'if' contributes zero
        #elements when the segment isn't present, one when it is, rather than needing separate
        #conditional appends
        $lines = @(
            $mainSegment
            if ($gitSegment) { $gitSegment }
            if ($pathText) { "[$pathText]" }
            "$arrowText "
        )

        $lines -join "`n"
    }
    else
    {
        "$mainSegment$gitSegment`n$location"
    }

}

function Set-PromptGitStatus
{

    <#
        .SYNOPSIS
            Turns the git status segment of the custom Prompt on or off for this session.

        .DESCRIPTION
            The git segment (branch, untracked/staged counts, ahead/behind) costs one 'git status'
            process spawn per prompt draw while inside a repository - roughly 20-30ms in testing,
            versus under 2ms for everywhere else. It's on by default; use -Off if you'd rather not
            pay that cost, and call Set-PromptGitStatus again with no parameters to turn it back on.

            Sets $global:promptParams.Settings.GitStatusEnabled (see Initialize-PromptParams).
            Like $global:thisTerminalName, this does not persist between terminal sessions - every
            new shell starts with the git segment enabled again.

        .PARAMETER Off
            Disables the git status segment. Omit to (re-)enable it.

        .EXAMPLE
            Set-PromptGitStatus -Off

            Turns the git status segment off for the rest of this session.

        .EXAMPLE
            Set-PromptGitStatus

            Turns the git status segment back on.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    PARAM(
        #Disables the git status segment. Omit to (re-)enable it.
        [Parameter()]
        [switch]$Off
    )

    process
    {
        Initialize-PromptParams
        $global:promptParams.Settings.GitStatusEnabled = -not $Off.IsPresent
        Write-Verbose "Git status segment is now $(if ($global:promptParams.Settings.GitStatusEnabled) { 'enabled' } else { 'disabled' })"
    }

}

function Set-PromptMultiLine
{

    <#
        .SYNOPSIS
            [Experimental] Toggles whether the custom Prompt renders as two lines or four.

        .DESCRIPTION
            The prompt normally renders on two lines: the main/git segments on the first, the
            path and '>' together on the second. When enabled, it spreads across up to four lines
            instead:
                mainSegment
                gitSegment (only present when there is one)
                [path]
                >

            Sets $global:promptParams.Settings.MultiLine (see Initialize-PromptParams). Off by
            default - every new shell starts on the two-line layout, and resets every new session
            like the rest of the prompt's state.

        .PARAMETER Off
            Returns to the two-line layout. Omit to enable the four-line layout.

        .EXAMPLE
            Set-PromptMultiLine

            Switches the prompt to the multi-line layout for the rest of this session.

        .EXAMPLE
            Set-PromptMultiLine -Off

            Returns to the two-line layout.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    PARAM(
        #Returns to the two-line layout. Omit to enable the four-line layout.
        [Parameter()]
        [switch]$Off
    )

    process
    {
        Initialize-PromptParams
        $global:promptParams.Settings.MultiLine = -not $Off.IsPresent
        Write-Verbose "Prompt layout is now $(if ($global:promptParams.Settings.MultiLine) { 'multi-line' } else { 'two-line' })"
    }

}

function Set-PromptParam
{

    <#
        .SYNOPSIS
            Customises a colour or threshold used by the custom Prompt.

        .DESCRIPTION
            Every colour and threshold the custom Prompt uses lives in one global hashtable,
            $global:promptParams (see Initialize-PromptParams for its shape and defaults). This is
            a guided way to change a value in it without reaching into the hashtable by hand -
            though doing that directly (e.g. $global:promptParams.Colors.Untracked = @(0,255,0))
            works exactly the same, since it's the same table underneath.

            Name is a dot-separated path into the table (e.g. 'Colors.Untracked' or
            'Colors.Duration.Maximum'). Initialize-PromptParams runs first, so this works even in a
            fresh session where $global:promptParams doesn't exist yet, and any segment of the path
            that doesn't already exist is created as a nested hashtable along the way.

            Like the rest of the prompt's global state, this does not persist between terminal
            sessions - every new shell starts back on the defaults.

        .PARAMETER Name
            A dot-separated path into $global:promptParams, e.g. 'Colors.Untracked' or
            'Settings.GitStatusEnabled'.

        .PARAMETER Value
            The value to set at that path.

        .EXAMPLE
            Set-PromptParam -Name 'Colors.Untracked' -Value @(0, 255, 0)

            Changes the U: (untracked file count) colour to green.

        .EXAMPLE
            Set-PromptParam -Name 'Colors.Duration.Maximum' -Value 45

            Changes the duration gradient so it reaches full red at 45 seconds instead of 60.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    PARAM(
        #A dot-separated path into $global:promptParams, e.g. 'Colors.Untracked'
        [Parameter(Mandatory, Position = 0)]
        [string]$Name,
        #The value to set at that path
        [Parameter(Mandatory, Position = 1)]
        [AllowNull()]
        $Value
    )

    process
    {
        Initialize-PromptParams

        $segments = $Name -split '\.'
        $node = $global:promptParams

        for ($i = 0; $i -lt $segments.Count - 1; $i++)
        {
            $segment = $segments[$i]

            if ($node[$segment] -isnot [hashtable])
            {
                $node[$segment] = @{}
            }

            $node = $node[$segment]
        }

        $node[$segments[-1]] = $Value
    }

}

function Set-TerminalName
{

    <#
        .SYNOPSIS
            Used with the Custom Prompt. A simple little helper function to help name the terminal window

        .DESCRIPTION
            Sometimes it makes sense to label your terminal window for easier identification.
            Stores the name in $global:thisTerminalName (used by the Custom Prompt) and, where
            supported, also sets the console window title to match.

            Control characters (escape sequences, tabs, newlines, etc.) are stripped from the
            name before it's stored or displayed. Setting the window title is skipped when
            console output is redirected or piped, such as during a CI run.

        .PARAMETER terminalName
            The label to use for the terminal window.

        .PARAMETER clear
            Clears the currently set terminal name and resets the window title to its default.

        .EXAMPLE
            Set-TerminalName "It's important to have goals 🥅"

            Sets the terminal name, and the window title where supported, to "It's important to have goals 🥅"

        .EXAMPLE
            Set-TerminalName -clear

            Clears the currently set terminal name and resets the window title to its default.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding(DefaultParameterSetName='Default')]
    PARAM(
        #What label do you want to use for your terminal
        [Parameter(ParameterSetName='Default',Mandatory,Position=0,ValueFromPipeline,ValueFromPipelineByPropertyName)]
        [string]$terminalName,
        #Use this to clear the currently set Terminal Name
        [Parameter(ParameterSetName='Clear')]
        [switch]$clear
    )
    begin{
        #Return the script name when running verbose, makes it tidier
        write-verbose "===========Executing $($MyInvocation.InvocationName)==========="
        #Return the sent variables when running debug
        Write-Debug "BoundParams: $($MyInvocation.BoundParameters|Out-String)"
        
    }
    
    process{
        #If the Parameter Set is Clear, then remove the existing Terminal Name
        if($PSCmdlet.ParameterSetName -eq 'Clear') 
        {
            Write-Verbose 'Clear triggered. Wiping existing parameter set'
            Remove-Variable -Scope Global -Name thisTerminalName -ErrorAction Ignore

            $defaultTitle = if($PSVersionTable.PSEdition -eq 'Desktop') { 'Windows PowerShell' } else { 'PowerShell' }

            #Skip touching the title when stdout is redirected/piped (e.g. CI runs) -
            #on non-Windows hosts the title is set by writing a raw ANSI escape sequence
            #to stdout, which would otherwise land in captured output/logs.
            if([Console]::IsOutputRedirected){
                Write-Verbose 'Output is redirected. Skipping Window Title clear.'
            }else{
                try{
                    $host.ui.rawui.WindowTitle = $defaultTitle
                    Write-Verbose 'Successfully cleared the Window Title'
                }catch{
                    Write-Verbose 'Missed on clearing Window Title'
                }
            }

        #Otherwise, set the terminal
        }else{
            #Strip control characters (ESC, BEL, newlines, tabs, etc.) so a stray one
            #can't inject an unintended escape sequence when the title is written out
            $terminalName = $terminalName -replace '[\x00-\x1F\x7F]', ''

            Write-Verbose "Setting Parameter set name to: $($terminalName)"
            #One of the rare occasions that using a global var makes sense and is justified
            $global:thisTerminalName = $terminalName

            #We should also try and inject it into the Window Title.
            #Just in case the Terminal doesn't support this specifically, we should wrap it
            #Skip touching the title when stdout is redirected/piped (e.g. CI runs) -
            #on non-Windows hosts the title is set by writing a raw ANSI escape sequence
            #to stdout, which would otherwise land in captured output/logs.
            if([Console]::IsOutputRedirected){
                Write-Verbose 'Output is redirected. Skipping Window Title set.'
            }else{
                try{
                    $host.ui.rawui.WindowTitle = $terminalName
                    Write-Verbose 'Successfully set the Window Title'
                }catch{
                    Write-Verbose 'Missed on setting Window Title.'
                }
            }

        }
            
    }
    
}
function ConvertFrom-GitStatusPorcelain
{

    <#
        .SYNOPSIS
            Parses the output of 'git status --porcelain=v2 --branch' into a summary object.

        .DESCRIPTION
            A pure text parser - it never runs git itself, so it's fully testable with canned
            sample output. Understands the porcelain v2 header lines (branch name, ahead/behind
            vs. upstream) plus the '1'/'2' (ordinary/renamed) tracked-entry lines and '?'
            untracked-entry lines.

            Each tracked-entry line carries two status characters, X (index) and Y (worktree).
            Staged count is the number of entries whose X status is not '.' - i.e. "changes to be
            committed". Modified count is the number of entries whose Y status is not '.' - i.e.
            changes made to a tracked file that haven't been staged yet. A file that's been
            modified and then staged (or modified again after staging) counts toward both.
            Ahead/Behind are only meaningful when the branch has an upstream configured, which is
            reflected in HasUpstream.

        .PARAMETER InputObject
            Lines of 'git status --porcelain=v2 --branch' output.

        .EXAMPLE
            git status --porcelain=v2 --branch | ConvertFrom-GitStatusPorcelain

            Parses the current repository's status into a summary object.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    [OutputType([pscustomobject])]
    PARAM(
        #Lines of 'git status --porcelain=v2 --branch' output
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$InputObject
    )

    begin
    {
        $branch = $null
        $hasUpstream = $false
        $ahead = 0
        $behind = 0
        $stagedCount = 0
        $modifiedCount = 0
        $untrackedCount = 0
    }

    process
    {
        foreach ($rawLine in $InputObject)
        {
            if ([string]::IsNullOrEmpty($rawLine))
            {
                continue
            }

            #Defensive only - git's porcelain output is documented as LF-terminated even on
            #Windows, and PowerShell's native-command line capture already strips line endings,
            #but a stray trailing \r would otherwise end up inside the '(.+)$' capture groups below
            $line = $rawLine.TrimEnd("`r")

            switch -Regex ($line)
            {
                '^# branch\.head (.+)$'
                {
                    $branch = $Matches[1]
                    continue
                }
                '^# branch\.ab \+(\d+) -(\d+)$'
                {
                    $ahead = [int]$Matches[1]
                    $behind = [int]$Matches[2]
                    $hasUpstream = $true
                    continue
                }
                '^[12] (\S)(\S) '
                {
                    if ($Matches[1] -ne '.')
                    {
                        $stagedCount++
                    }
                    if ($Matches[2] -ne '.')
                    {
                        $modifiedCount++
                    }
                    continue
                }
                '^\? '
                {
                    $untrackedCount++
                    continue
                }
            }
        }
    }

    end
    {
        [pscustomobject]@{
            Branch         = $branch
            HasUpstream    = $hasUpstream
            Ahead          = $ahead
            Behind         = $behind
            StagedCount    = $stagedCount
            ModifiedCount  = $modifiedCount
            UntrackedCount = $untrackedCount
        }
    }

}

function Find-GitRepositoryRoot
{

    <#
        .SYNOPSIS
            Walks up from a starting directory looking for a .git entry, to answer "is this
            directory a git repository or a child of one" without spawning a git process.

        .DESCRIPTION
            Repeatedly checks for a '.git' entry - a directory for a normal repository, or a file
            for a worktree/submodule, both of which are valid "this is a repo" markers - in the
            given path and each of its parents, stopping at the filesystem root.

            This is a pure filesystem check - no external process is started - so it's cheap to
            call on every prompt draw even for the (likely common) case of not being inside a
            repository at all.

        .PARAMETER Path
            The directory to start searching from. Defaults to the current location.

        .EXAMPLE
            Find-GitRepositoryRoot -Path 'C:\repos\MyProject\src\deep\folder'

            Returns 'C:\repos\MyProject' if a .git entry exists there, or in any parent up to the
            filesystem root. Returns $null if none is found.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    [OutputType([string])]
    PARAM(
        #Directory to start searching from
        [Parameter()]
        [string]$Path = $(Get-Location).Path
    )

    process
    {
        $current = $Path

        while ($current)
        {
            $gitEntry = Join-Path -Path $current -ChildPath '.git'

            if (Test-Path -LiteralPath $gitEntry)
            {
                return $current
            }

            $parent = Split-Path -Path $current -Parent

            if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current)
            {
                return $null
            }

            $current = $parent
        }
    }

}

function Get-GitStatusPorcelainText
{

    <#
        .SYNOPSIS
            Runs 'git status --porcelain=v2 --branch' against a repository and returns its raw output lines.

        .DESCRIPTION
            A thin process-invocation wrapper - deliberately does nothing but shell out and hand
            back text. Keeping it this small means callers (like Prompt) can substitute this one
            seam in tests instead of depending on a real git binary and repository.

            Any failure (git errors out, path is bad, etc.) is swallowed and returns nothing rather
            than throwing - this feeds decorative prompt output, not something that should ever be
            allowed to break a shell.

        .PARAMETER GitPath
            Path to the git executable.

        .PARAMETER RepositoryRoot
            Repository root to run the status command against (passed to git's -C).

        .EXAMPLE
            Get-GitStatusPorcelainText -GitPath 'C:\Program Files\Git\cmd\git.exe' -RepositoryRoot 'C:\repos\MyProject'

            Returns the raw 'git status --porcelain=v2 --branch' output lines for that repository.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    [OutputType([string[]])]
    PARAM(
        #Path to the git executable
        [Parameter(Mandatory)]
        [string]$GitPath,
        #Repository root to run the status command against
        [Parameter(Mandatory)]
        [string]$RepositoryRoot
    )

    process
    {
        try
        {
            & $GitPath -C $RepositoryRoot status --porcelain=v2 --branch 2>$null
        }
        catch
        {
            Write-Verbose "Failed to read git status: $_"
        }
    }

}

function Get-PromptBranchColor
{

    <#
        .SYNOPSIS
            Returns an ANSI truecolor foreground escape sequence for a git branch name.

        .DESCRIPTION
            'main' and 'master' (case-insensitive) always get orange. Any other branch name gets
            one of three colours - LightBlue, LightPurple, Green - chosen by a stable hash of the
            branch name, so the same branch always gets the same colour across sessions.

            A stable hash is used deliberately rather than .NET's default string hashing: string
            .GetHashCode() is randomised per process in modern .NET, so it would give a different
            colour to the same branch every time a new shell is opened.

        .PARAMETER BranchName
            The git branch name to pick a colour for.

        .PARAMETER MainColor
            RGB triple used for 'main'/'master'. Defaults to Orange (255, 165, 0) - the caller
            (Prompt) normally passes $global:promptParams.Colors.BranchMain instead.

        .PARAMETER Palette
            Array of RGB triples to hash any other branch name into. Defaults to
            LightBlue/LightPurple/Green - the caller (Prompt) normally passes
            $global:promptParams.Colors.BranchPalette instead.

        .EXAMPLE
            Get-PromptBranchColor -BranchName 'main'

            Returns the ANSI escape sequence for orange.

        .EXAMPLE
            Get-PromptBranchColor -BranchName 'feature/widgets'

            Returns one of LightBlue/LightPurple/Green, always the same one for this exact name.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    [OutputType([string])]
    PARAM(
        #The git branch name to pick a colour for
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$BranchName,
        #RGB triple used for 'main'/'master'
        [Parameter()]
        [int[]]$MainColor = @(255, 165, 0),
        #Array of RGB triples to hash any other branch name into
        [Parameter()]
        [object[]]$Palette = @(
            @(173, 216, 230), #LightBlue
            @(238, 130, 238), #LightPurple
            @(50, 205, 50)    #Green
        )
    )

    process
    {
        if ($BranchName -in @('main', 'master'))
        {
            return $PSStyle.Foreground.FromRgb($MainColor[0], $MainColor[1], $MainColor[2])
        }

        #FNV-1a, 32-bit - simple, deterministic, and stable across sessions/processes (unlike
        #the runtime-randomised default .GetHashCode()). Uses modulo rather than -band to stay
        #within 32 bits, since -band against the (UInt32-typed) 0xFFFFFFFF literal fights with
        #the Int64 accumulator and silently fails to mask, letting the value overflow.
        [int64]$hash = 2166136261
        foreach ($character in $BranchName.ToCharArray())
        {
            $hash = ($hash -bxor [int64][char]$character) % 4294967296
            $hash = ($hash * 16777619) % 4294967296
        }

        $selected = $Palette[$hash % $Palette.Count]
        $PSStyle.Foreground.FromRgb($selected[0], $selected[1], $selected[2])
    }

}

function Get-PromptColoredPath
{

    <#
        .SYNOPSIS
            Colours a PowerShell location string for the prompt: drive/PSDrive prefix blue, path
            dividers purple.

        .DESCRIPTION
            Splits a location string (e.g. 'C:\repos\Prompt55' or 'HKLM:\SOFTWARE') into its
            drive prefix - anything up to and including the first ':' - and the remainder. The
            drive prefix is coloured blue; every path divider ('\' or '/') found in the remainder
            is coloured purple. Everything else (the actual segment names) is left in the host's
            default foreground colour.

            A path with no drive prefix (e.g. a UNC path, or a root-relative path on a
            non-Windows host) is coloured on dividers alone - there's no drive segment to
            highlight.

            A pure string function - no console/host access - so it's cheap to call from a hot
            path like a custom prompt and easy to unit test in isolation. Only meant to be called
            when the host supports ANSI virtual terminal sequences; the caller is expected to fall
            back to the plain path otherwise.

        .PARAMETER Path
            The location string to colour, e.g. as returned by
            $executionContext.SessionState.Path.CurrentLocation.

        .PARAMETER DriveColor
            RGB triple for the drive/PSDrive prefix. Defaults to DodgerBlue (30, 144, 255) - the
            caller (Prompt) normally passes $global:promptParams.Colors.PathDrive instead.

        .PARAMETER DividerColor
            RGB triple for each path divider ('\' or '/'). Defaults to Purple (128, 0, 128) - the
            caller (Prompt) normally passes $global:promptParams.Colors.PathDivider instead.

        .EXAMPLE
            Get-PromptColoredPath -Path 'C:\repos\Prompt55'

            Returns 'C:' in blue, followed by '\repos\Prompt55' with each '\' coloured purple.

        .EXAMPLE
            Get-PromptColoredPath -Path 'HKLM:\SOFTWARE'

            Colours the 'HKLM:' drive prefix the same way as a filesystem drive.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    [OutputType([string])]
    PARAM(
        #The location string to colour
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,
        #RGB triple for the drive/PSDrive prefix
        [Parameter()]
        [int[]]$DriveColor = @(30, 144, 255),
        #RGB triple for each path divider ('\' or '/')
        [Parameter()]
        [int[]]$DividerColor = @(128, 0, 128)
    )

    process
    {
        $reset = $PSStyle.Reset
        $driveColorCode = $PSStyle.Foreground.FromRgb($DriveColor[0], $DriveColor[1], $DriveColor[2])
        $dividerColorCode = $PSStyle.Foreground.FromRgb($DividerColor[0], $DividerColor[1], $DividerColor[2])

        if ($Path -match '^([^\\/]+:)(.*)$')
        {
            $driveText = "$driveColorCode$($Matches[1])$reset"
            $remainder = $Matches[2]
        }
        else
        {
            $driveText = ''
            $remainder = $Path
        }

        #$0 re-inserts the whole match (the divider character itself) wrapped in colour codes,
        #so this works whether the provider uses '\' (Windows) or '/' (everywhere else) without
        #having to special-case either
        $dividerReplacement = $dividerColorCode + '$0' + $reset
        $remainderText = [regex]::Replace($remainder, '[\\/]', $dividerReplacement)

        "$driveText$remainderText"
    }

}

function Get-PromptGradientColor
{

    <#
        .SYNOPSIS
            Returns an ANSI truecolor foreground escape sequence for a value's position within a hue gradient.

        .DESCRIPTION
            Clamps Value into the Minimum/Maximum range, then interpolates a hue between StartHue
            and EndHue based on where the (clamped) value falls in that range - linearly by
            default, or logarithmically with -Logarithmic. The resulting hue, at full saturation
            and brightness, is converted to RGB and returned as an ANSI truecolor foreground
            escape sequence via $PSStyle.Foreground.FromRgb().

            This is a pure function - no console/host access, no cmdlet calls beyond the final
            FromRgb() lookup - so it's cheap enough to call repeatedly from a hot path like a
            custom prompt, and easy to unit test in isolation.

            Used to build gradients such as blue-to-red (a "rainbow" sweep, hue 240 -> 0) for a
            time-of-day scale, or green-to-red (hue 120 -> 0) for a duration/heat scale.

        .PARAMETER Value
            The value to place within the gradient.

        .PARAMETER Minimum
            The value that maps to StartHue. Values below this clamp to Minimum. With
            -Logarithmic, must be -1 or greater (it feeds log(Minimum + 1)).

        .PARAMETER Maximum
            The value that maps to EndHue. Values above this clamp to Maximum.

        .PARAMETER StartHue
            Hue, in degrees (0-360), used when Value is at or below Minimum.

        .PARAMETER EndHue
            Hue, in degrees (0-360), used when Value is at or above Maximum.

        .PARAMETER Logarithmic
            Interpolates on a log(Value + 1) scale instead of linearly, so the hue moves away from
            StartHue much faster for small values and levels off as Value approaches Maximum.
            Matches how duration "feels" - the difference between 1s and 2s is far more noticeable
            than the difference between 50s and 51s. The '+ 1' shift keeps the log defined (and the
            ratio well-behaved) even when Minimum is 0.

        .EXAMPLE
            Get-PromptGradientColor -Value 0 -Minimum 0 -Maximum 23 -StartHue 240 -EndHue 0

            Returns the ANSI escape sequence for pure blue (hue 240), since Value sits at Minimum.

        .EXAMPLE
            Get-PromptGradientColor -Value 90 -Minimum 1 -Maximum 60 -StartHue 120 -EndHue 0

            Value is clamped to Maximum (60), so this returns the ANSI escape sequence for pure red (hue 0).

        .EXAMPLE
            Get-PromptGradientColor -Value 10 -Minimum 0 -Maximum 60 -StartHue 120 -EndHue 0 -Logarithmic

            Returns an amber-ish colour - on this log scale, 10 out of a 0-60 range has already moved
            well past green, unlike the ~80% still-green result a linear interpolation would give.

        .NOTES
            Author: Adrian Andersson
    #>


    [CmdletBinding()]
    [OutputType([string])]
    PARAM(
        #The value to place within the gradient
        [Parameter(Mandatory)]
        [double]$Value,
        #The value that maps to StartHue - anything below this clamps to it
        [Parameter(Mandatory)]
        [double]$Minimum,
        #The value that maps to EndHue - anything above this clamps to it
        [Parameter(Mandatory)]
        [double]$Maximum,
        #Hue (0-360) used when Value is at or below Minimum
        [Parameter(Mandatory)]
        [ValidateRange(0, 360)]
        [double]$StartHue,
        #Hue (0-360) used when Value is at or above Maximum
        [Parameter(Mandatory)]
        [ValidateRange(0, 360)]
        [double]$EndHue,
        #Interpolates on a log(Value + 1) scale instead of linearly - see .PARAMETER Logarithmic
        [Parameter()]
        [switch]$Logarithmic
    )

    process
    {
        #Clamp the value into range, then normalize it to a 0..1 ratio across that range
        $clampedValue = [math]::Min([math]::Max($Value, $Minimum), $Maximum)

        if ($Logarithmic)
        {
            #'+ 1' shift so log() stays defined (and the ratio well-behaved) even when Minimum is 0
            $logMinimum = [math]::Log($Minimum + 1)
            $logMaximum = [math]::Log($Maximum + 1)
            $logValue = [math]::Log($clampedValue + 1)
            $ratio = if ($logMaximum -eq $logMinimum) { 0 } else { ($logValue - $logMinimum) / ($logMaximum - $logMinimum) }
        }
        else
        {
            $ratio = if ($Maximum -eq $Minimum) { 0 } else { ($clampedValue - $Minimum) / ($Maximum - $Minimum) }
        }

        $hue = $StartHue + (($EndHue - $StartHue) * $ratio)

        #Standard HSV (hue, saturation=1, value=1) to RGB sector conversion
        $chroma = 1
        $huePrime = $hue / 60
        $secondLargest = $chroma * (1 - [math]::Abs(($huePrime % 2) - 1))

        switch ([math]::Floor($huePrime) % 6)
        {
            0 { $red, $green, $blue = $chroma, $secondLargest, 0 }
            1 { $red, $green, $blue = $secondLargest, $chroma, 0 }
            2 { $red, $green, $blue = 0, $chroma, $secondLargest }
            3 { $red, $green, $blue = 0, $secondLargest, $chroma }
            4 { $red, $green, $blue = $secondLargest, 0, $chroma }
            default { $red, $green, $blue = $chroma, 0, $secondLargest }
        }

        $PSStyle.Foreground.FromRgb([int][math]::Round($red * 255), [int][math]::Round($green * 255), [int][math]::Round($blue * 255))
    }

}

function Initialize-PromptParams
{

    <#
        .SYNOPSIS
            Ensures $global:promptParams exists and has every default key, without clobbering
            anything the user (or Set-PromptParam) has already customised.

        .DESCRIPTION
            $global:promptParams is the single hashtable that holds every tunable colour and
            threshold used by the custom Prompt, plus its on/off toggles (Settings.GitStatusEnabled,
            Settings.HideTerminalPath, Settings.MultiLine). This function is the "check for and
            load defaults" step:

              - If $global:promptParams doesn't exist yet (or isn't a hashtable), it's set to a
                fresh copy of the defaults outright.
              - Otherwise, the defaults are recursively merged in: any key present in the defaults
                but missing from $global:promptParams (at any nesting level) is filled in; any key
                the user has already set is left completely untouched.

            Called unconditionally - and cheaply, it's just hashtable key lookups - at the start of
            every function that reads or writes $global:promptParams (Prompt, Set-PromptGitStatus,
            Hide-TerminalPath, Set-PromptMultiLine, Set-PromptParam), so the table is always safe
            to read from and self-heals if something clears a key mid-session.

        .EXAMPLE
            Initialize-PromptParams

            Creates $global:promptParams from defaults if it doesn't exist, or backfills any
            missing keys if it does.

        .NOTES
            Author: Adrian Andersson

    #>


    [CmdletBinding()]
    PARAM()

    process
    {
        #Recursively fills in any key missing from $Target that exists in $Defaults, at any
        #nesting level, without touching a key $Target already has. Scoped inside this function
        #(rather than living as its own private function/file) since it's purely an
        #implementation detail of the merge below.
        function Merge-PromptParamDefaults
        {
            PARAM(
                [Parameter(Mandatory)]
                [hashtable]$Target,
                [Parameter(Mandatory)]
                [hashtable]$Defaults
            )

            foreach ($key in $Defaults.Keys)
            {
                if (-not $Target.ContainsKey($key))
                {
                    $Target[$key] = $Defaults[$key]
                }
                elseif ($Defaults[$key] -is [hashtable] -and $Target[$key] -is [hashtable])
                {
                    Merge-PromptParamDefaults -Target $Target[$key] -Defaults $Defaults[$key]
                }
            }
        }

        $defaults = @{
            Colors   = @{
                Time          = @{
                    StartHue = 240
                    EndHue   = 0
                }
                Duration      = @{
                    StartHue = 120
                    EndHue   = 0
                    Minimum  = 0
                    Maximum  = 60
                }
                Untracked     = @(255, 0, 0) #Red - U:
                Modified      = @(0, 255, 255) #Cyan - M:
                Staged        = @(30, 144, 255) #DodgerBlue - S:
                Commits       = @(255, 255, 0) #Yellow - C:
                BranchMain    = @(255, 165, 0) #Orange - main/master
                BranchPalette = @(
                    @(173, 216, 230), #LightBlue
                    @(238, 130, 238), #LightPurple
                    @(50, 205, 50) #Green
                )
                PathDrive     = @(30, 144, 255) #DodgerBlue
                PathDivider   = @(128, 0, 128) #Purple
                PathArrow     = @(0, 255, 255) #Cyan
                Success       = @(0, 255, 0) #Green - the last command status symbol, on success
                Failure       = @(255, 0, 0) #Red - the last command status symbol, on failure
            }
            Settings = @{
                GitStatusEnabled = $true
                HideTerminalPath = $false
                MultiLine        = $false
            }
        }

        if ($global:promptParams -isnot [hashtable])
        {
            $global:promptParams = $defaults
        }
        else
        {
            Merge-PromptParamDefaults -Target $global:promptParams -Defaults $defaults
        }
    }

}