GitServe.psm1

<#
.Description
    Module built on: 2026-08-24 13:54:50Z
#>


#region Module.Before.ps1

Import-Module ugit

# Ensure http outputs default to utf8
$OutputEncoding = (
    [Console]::OutputEncoding = [Console]::InputEncoding =
    [System.Text.UTF8Encoding]::new( <# bool: encoderShouldEmitUTF8Identifier #> $false )
)

# Core config passed to ThreadJobs
$script:ModuleState = [hashtable]::Synchronized(@{
    HostName = $null
    Port = $null
    JobName = $null
    Using_CleanupOnRemoveEvent = $true
    CorsAllowOrigin = @('*')
    CorsAllowMethods = 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD'
    CorsAllowHeaders = 'Content-Type, Authorization, X-Requested-With'
    CorsAllowCredentials = $false

    ClonedRepoRoot = @( 'c:/GitLoggerApp/ClonedRepos', '/cloned-repos' ) # configure with: GitServe.Set-ConfigRepoRoot

    JsonCacheRepoList = Join-Path $env:LocalAppData 'GitServe\Cache\RepoList.json'  # fix(portability): make defaults cross platform like linux ~/.GitServe/Cache/RepoList.json
})

# Core shared cache # nyi
$script:ResponseCache = [hashtable]::Synchronized(@{})

[Net.HttpListener] $script:Listener = [Net.HttpListener]::new()

#region Init Json Cache for RepoList
if( -not ( Test-Path ( $script:ModuleState.JsonCacheRepoList ) ) ) {
    New-Item -path $script:ModuleState.JsonCacheRepoList -Force
}
#endregion Init Json Cache for RepoList


#endregion Module.Before.ps1


#region Private Module Functions

function Clear-ResponseCacheKey {
    <#
    .SYNOPSIS
        (internal) Clears cache by key names
    .NOTES
        Currently it allows you to set the value to null
    #>

    [CmdletBinding()]
    param(
        [Alias('Name') ]
        [Parameter(Mandatory)]
        [string] $KeyName
    )
    "Clear-ResponseCache -Key '${KeyName}'" | Write-Verbose
    $cache = $Script:ResponseCache
    $cache.Remove( $KeyName ) # safe for missing keys
}

function Get-ResponseCache {
    <#
    .SYNOPSIS
        (internal) Read shared response cache
    #>

    param(
        # Missing keys will return null
        [Alias('Name', 'Key' ) ]
        [Parameter(Mandatory)]
        [string] $KeyName,

        # Only test, returns true if the key exists
        [Alias('TestOnly')]
        [bool] $HasKey
    )
    $cache = $Script:ResponseCache

    $exists = $cache.ContainsKey( $KeyName )
    if( $HasKey ) {
        return $exists
    }

    return $cache[ $KeyName ]
}

function InvokeCli.Git.CloneRepo { # or FromDictionaryEntry
    <#
    .SYNOPSIS
        (internal) Invoke native git clone, and create folders based on the url: '/<root>/<owner>/<repo>'
    .DESCRIPTION
        ClonedRepoRoot - '/cloned-repos' will clone to '/cloned-repos/owner/repository'
    .EXAMPLE
        InvokeCli.Git.CloneRepo -CloneUrl 'https://github.com/owner/repo.git'
    .EXAMPLE
        # change root and print debug info
        InvokeCli.Git.CloneRepo -CloneUrl 'https://github.com/owner/repo.git' -Path '/cloned-repos' -PSHost -Verbose
    .LINK
        GitServe\Invoke-GitClone
    .LINK
        GitServe\GitServe.Clone
    #>

    [CmdletBinding()]
    param(
        [Alias('Url')]
        [Parameter(Mandatory)]
        [string] $CloneUrl,

        # root directory to clone under. '/cloned-repos' would clone to '/cloned-repos/owner/repository'
        [Alias('Path', 'PSPath')]
        [string] $ClonedRepoRoot,

        # Write to host
        [Alias('VerboseOutput')]
        [switch] $PSHost
    )

    $OriginalPath = Get-Item '.'
    if ( [String]::IsNullOrWhitespace( $ClonedRepoRoot ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'Path: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }

    $uriPrefix, $OwnerName, $RepoName = $CloneUrl -split '/', -3
    $RepoName = $RepoName -replace '\.git$'

    <#
    example uri output values:
        > $cloneUrl = 'https://github.com/BurntSushi/ripgrep.git'
        > $uriPrefix, $OwnerName, $RepoName
        https://github.com, BurntSushi, ripgrep.git
    #>


    [ordered]@{ OwnerName = $OwnerName; RepoName = $RepoName; UriPrefix = $UriPrefix ; CloneUrl = $CloneUrl; ClonedRepoRoot = $ClonedRepoRoot }
        | ConvertTo-Json -Compress -depth 2
        | Write-Verbose

    if( [String]::IsNullOrWhiteSpace( $OwnerName ) ) {
        throw "OwnerName from the CloneUrl is blank!"
    }
    $OwnerRoot = Join-Path $ClonedRepoRoot $OwnerName
    if( -not ( Test-Path $OwnerRoot ) ) {
        $OwnerRoot = New-Item -ItemType Directory -Path $OwnerRoot -ea 'stop'
    }

    Set-Location -Path $OwnerRoot -ea 'stop' # note(threading): May need to remove provider use for threading

    # Run real git with args:
    #region Invoke Real Git Args
    $binGit = Get-Command -CommandType Application -Name 'git' -ea 'Stop' -TotalCount 1
    [Collections.Generic.List[object]] $gitArgs = @(
        'clone'
        $CloneUrl
        # $OwnerRoot # if not using provider, declare path
    )

    if( -not (Test-Path (Join-Path $OwnerRoot $OwnerName)) ) {
        $gitArgs
            | Join-String -sep ' ' -op 'Clone: invoke ''git'' => '
            | Write-Verbose

        $results = & $binGit @gitArgs
        if( $PSHost ) {
            $Results | Write-Host
        }
        # $results
    } else {
        if( $PSHost ) {
        "Directory '${ownerName}' already exists. Skipping clone."
            | Write-Host -fg 'Green'
        }
    }

    Set-Location -Path $OriginalPath
    #endregion Invoke Real Git Args
}

function InvokeCli.Git.LsTree.Files {
    <#
    .SYNOPSIS
        (internal) Invoke native git ls-tree to list files
    .EXAMPLE
        InvokeCli.Git.LsTree.Files -Repo 'https://github.com/owner/repo.git'
    #>

    # [Alias('InvokeCli.Git.LsTree.Files')]
    [CmdletBinding()]
    param(
        # root directory to clone under. '/cloned-repos' would clone to '/cloned-repos/owner/repository'
        [Parameter(Mandatory)]
        [Alias('Path', 'PSPath', 'GitRepo', 'RepoRoot', 'FromPath')]
        [string] $GitRepositoryPath,

        # default uses 'ls-tree --full-tree'
        [switch] $WithoutIncludeFullTree
    )
    #region Invoke RealGit
    $gitArgs
        | Join-String -sep ' ' -op 'invoke ''git'' => '
        | Write-Verbose

    $realGit_splat = @{
        FromPath = Get-Item -ea 'stop' $GitRepositoryPath
        GitArgList = @(
            'ls-tree'
            '-r'
            'HEAD'
            if( $WithoutIncludeFullTree ) { '--full-tree' }
            '--name-only'
        )
    }

    $results = GitServe.Invoke-RealGit @realGit_splat
    $results
    #endregion Invoke RealGit
}

function OnRemoveModule_Handler {
    <#
    .synopsis
        Free resources when module unloads: Cleanup threads and HttpListeners
    .description
        Automatically called by event: '$ExecutionContext.SessionState.Module.OnRemove'
    #>

    "GitServe: OnRemove => Cleaning up HttpListener and ThreadJobs..." | Write-Host -Fore 'Yellow'
    Stop-GitServe
}

function New-HtmlTemplate {
    <#
    .SYNOPSIS
        Return a bare-bones html doc with the right charset
    #>

    param(
        [string] $Title = 'GitServe',

        [Alias('Content')]
        [string] $HtmlContent = '<h1>GitLogger</h1>'
    )
    [string] $template = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>${Title}</title>
</head>
<body>
${HtmlContent}
</body>
</html>
"@

    $template -join [Environment]::NewLine
}

function Convert-GitServeQueryString {
    <#
    .SYNOPSIS
        (internal) Parse request query strings. returns the named value collection
    .NOTES
        You can pass a raw Url or an HttpListenerRequest instance
        This does not use [ValueFromPipelineByPropertyName]
    #>

    [Alias(
        'ParseQueryString',
        'GitServe.Convert.RequestQueryString'
    )]
    [OutputType(
        [System.Collections.Specialized.NameValueCollection]
    )]
    [CmdletBinding()]
    param(
        # a raw Url or from an HttpListenerRequest instance
        [Alias( 'HttpListenerRequest', 'Request', 'Listener', 'RawUrl', 'Url' )]
        [Parameter(Mandatory, ValueFromPipeline )]
        [object] $ListenerOrUrl
    )

    begin { }
    process {
        if ( $null -eq $ListenerOrUrl ) {
            throw "ParseQueryString: Blank Url/ListenerRequest!"
        }

        if( $ListenerOrUrl -is [System.Net.HttpListenerRequest] ) {
            [System.Net.HttpListenerRequest] $Listener = $ListenerOrUrl
            [Collections.Specialized.NameValueCollection] $keyCollection =
                [Web.HttpUtility]::ParseQueryString( $Listener.Url.Query.ToLower() )

            return ,$keyCollection
        }

        if( $ListenerOrUrl -is [System.Uri]) {
            [System.Uri] $Url = $ListenerOrUrl
            [Collections.Specialized.NameValueCollection] $keyCollection =
                [Web.HttpUtility]::ParseQueryString( $Url.Query.ToLower() )

            return ,$keyCollection
        }

        throw "Unhandled Object type! $( ( $ListenerOrUrl )?.GetType() ) "    }
    end {
    }
}

function Set-CorsHeader {
    <#
    .SYNOPSIS
        (internal) Set CORS headers
    #>

    param(
        [Parameter(Mandatory)] [Net.HttpListenerRequest] $Request,
        [Parameter(Mandatory)] [Net.HttpListenerResponse] $Response,
        [Parameter(Mandatory)] [hashtable] $State
    )

    [string[]] $allowOrigins = @($State.CorsAllowOrigin)
    if (-not $allowOrigins -or $allowOrigins.Count -eq 0) {
        $allowOrigins = @('*')
    }

    $origin = $Request.Headers['Origin']
    $allowOriginHeader = $null

    if ($allowOrigins -contains '*') {
        if ($State.CorsAllowCredentials -and $origin) {
            $allowOriginHeader = $origin
        }
        else {
            $allowOriginHeader = '*'
        }
    }
    elseif ($origin -and ($allowOrigins -contains $origin)) {
        $allowOriginHeader = $origin
    }

    if ($allowOriginHeader) {
        $Response.Headers['Access-Control-Allow-Origin'] = $allowOriginHeader
    }

    if ($origin -and $allowOriginHeader -and $allowOriginHeader -ne '*') {
        $Response.Headers['Vary'] = 'Origin'
    }

    if ($State.CorsAllowCredentials -and $allowOriginHeader -and $allowOriginHeader -ne '*') {
        $Response.Headers['Access-Control-Allow-Credentials'] = 'true'
    }

    if ($State.CorsAllowMethods) {
        $Response.Headers['Access-Control-Allow-Methods'] = $State.CorsAllowMethods
    }

    $requestHeaders = $Request.Headers['Access-Control-Request-Headers']
    if ($requestHeaders) {
        $Response.Headers['Access-Control-Allow-Headers'] = $requestHeaders
    }
    elseif ($State.CorsAllowHeaders) {
        $Response.Headers['Access-Control-Allow-Headers'] = $State.CorsAllowHeaders
    }
}

function Set-ResponseCache {
    <#
    .SYNOPSIS
        (internal) Writes to shared response cache
    .NOTES
        Currently it allows you to set the value to null
    #>

    param(
        [Alias('Name') ]
        [Parameter(Mandatory)]
        [string] $KeyName,

        # Only test, returns true if the key exists
        [Alias('Object')]
        $Value
    )
    $cache = $Script:ResponseCache
    $cache[ $keyName ] = $Value
}

#region Watch for events
function Start-ListenLoop {
    <#
    .synopsis
        (internal) Main HttpListener loop ( Called by Start-GitServe )
    #>

    [CmdletBinding()]
    param(
        [ValidateNotNull()]
        [Parameter(Mandatory)]
        [Net.HttpListener] $Listener,

        # log request debug info to the console
        [Alias('DebugInfo')]
        [switch] $PSHost
    )

    if( $null -eq $Listener ) {
        Write-Warning 'Start-ListenLoop: Listener is null!'
        # throw "Start-ListenLoop: Listener is Null"
        write-error "Start-ListenLoop: Listener is Null"
        return # test non-terminating
    }

    # While the listener is listening:
    while ($Listener.IsListening) {
        # Get every http* event
        foreach ($event in @(Get-Event HTTP*)) {
            [Management.Automation.PSEventArgs] $event = $event
            # Try to get the context, request, and response from the event
            $context, $request, $response = $event.SourceArgs

            # enable static completions using types
            [Net.HttpListenerContext] $context   = $context
            [Net.HttpListenerRequest] $request   = $request
            [Net.HttpListenerResponse] $response = $response

            # and if there is no output stream, continue
            if (-not $response.OutputStream) {
                continue
            }

            Set-CorsHeader -Request $request -Response $response -State $script:ModuleState

            if ($request.HttpMethod -eq 'OPTIONS' -and $request.Headers['Origin']) {
                $response.StatusCode = 204
                $response.Close()
                $event | Remove-Event
                continue
            }

            # If we haven't already, cache a pointer to possible routes.
            if (-not $script:PossibleRoutes) {
                # (in this case, we'll presume any command with a slash in it could be a route)
                $script:PossibleRoutes = $ExecutionContext.SessionState.InvokeCommand.GetCommands('*/*', 'Alias,Function', $true)
            }

            $mappedCommand = $null

            $schemeAndHostSegment = $request.Url.Scheme,
            '://',
            $request.Url.DnsSafeHost -join ''

            $portSegment =
            if ($request.Url.Port -notin '80', '443') {
                ':' + $request.Url.Port
            }

            # Now let's create a list of possible route names for this request, in the order we'd prefer them
            $possibleRouteNames = @(
                # $schemeAndHostSegment, $portSegment, $request.Url.LocalPath -join ''
                # $schemeAndHostSegment, $request.Url.LocalPath -join ''
                # "$schemeAndHostSegment/"
                # $schemeAndHostSegment
                $request.Url.LocalPath
                # For this example, we'll just use the local path.
                # (this will work for a single server, for multitenant hosting, you'd need to include the host)
            )

            if( $PSHost ) {
                '{0} {1} ' -f @(
                    $request.HttpMethod
                    $request.Url
                ) | Write-Host -ForegroundColor 'gray60'
            }

            # Now we'll loop through the possible route names
            foreach ($possibleRouteName in $possibleRouteNames ) {
                # and see if a command exists for that route
                $commandExists = @($script:PossibleRoutes -match "^$([Regex]::Escape($possibleRouteName))$")[0]
                if ($commandExists) {
                    $mappedCommand = $commandExists
                    break
                }
            }

            # If we've mapped a command
            if ($mappedCommand) {
                if( $PSHost ) {
                    'Mapped to {0}' -f $mappedCommand | Write-Host -ForegroundColor 'gray60'
                }
                # Run it, and capture all of the streams
                $cmdParams = @{
                    Request = $Request
                }

                [string] $requestCacheKey = $Request.Url.PathAndQuery
                $result = Get-ResponseCache -Key $requestCacheKey

                [string[]] $NeverCacheRouteNames = @(
                    '/cache/list', '/cache/request/clear', '/cache/clear'
                )

                $neverCacheResponse = $requestCacheKey -in $NeverCacheRouteNames
                if( $null -eq $result -or $neverCacheResponse ) {
                    if( $PSHost ) {
                        ' Cache key is stale: "{0}" ( neverCache: {1} )' -f @(
                            $requestCacheKey
                            $neverCacheResponse
                        ) | Write-Host -fg 'yellow'
                    }
                    # Cache is stale, so invoke the Url Route
                    try {
                        # future: 'ParseQueryString()'
                        $result = . $mappedCommand @cmdParams # *>&1
                    } catch {
                        $response.StatusCode = [System.Net.HttpStatusCode]::InternalServerError
                        $result = [pscustomobject]@{
                            PSTypeName = 'GitServe.Route.Error'
                            Message = 'Invalid Route'
                            Route      = $mappedCommand.Name
                            Query      = $request.Url.PathAndQuery
                            Request    = $Request.Url.ToSTring()
                            Error      = $_.Exception.Message.ToString()
                            # StackTrace = $_.Exception.StackTrace
                        }
                    }

                    if( -not $neverCacheResponse ) {
                        Set-ResponseCache -Key $requestCacheKey -Value $result
                    }
                }

                # The result can tell us it is a content type by giving itself a content type as a type name
                $ContentTypePattern = '^(?>audio|application|font|image|message|model|text|video)/.+?'
                $resultIsContentType = @($result.pstypenames -match $ContentTypePattern)[0]
                # If the result was a content type
                if ($resultIsContentType) {
                    # set that header
                    $response.ContentType = $resultIsContentType
                }

                # If the result was a string
                if ($result -is [int] -and $result -ge 300 -and $result -lt 600) {
                    # set the status code
                    $response.StatusCode = $result
                    $response.Close()
                }
                elseif ($result -is [string]) {
                    # encode it using $OutputEncoding and close the response
                    $response.Close( $outputEncoding.GetBytes( $result ), $false ) # warning: assumes user set default non-ascii
                }
                # If the result was a byte[]
                elseif ($result -is [byte[]]) {
                    # respond with the bytes
                    $response.Close( $result, $false )
                }
                elseif ($result -is [IO.FileInfo]) {
                    # This block may want to be rewritten from old template
                    $BufferSize = 1mb
                    $serveFileJob = Start-ThreadJob -Name ($Request.Url -replace '^https?', 'file') -ScriptBlock {
                        param($result, $Request, $response, $BufferSize = 1mb)
                        if ($request.Method -eq 'HEAD') {
                            $response.ContentLength64 = $result.Length
                            $response.Close()
                            return
                        }

                        $response.Headers['Accept-Ranges'] = 'bytes'
                        $range = $request.Headers['Range']
                        $rangeStart, $rangeEnd = 0, 0
                        $fileStream = [IO.File]::OpenRead($result.Fullname)
                        if ($range) {
                            $null = $range -match 'bytes=(?<Start>\d{1,})(-(?<End>\d{1,})){0,1}'
                            $rangeStart, $rangeEnd = ($matches.Start -as [long]), ($matches.End -as [long])
                        }
                        if ($rangeStart -gt 0 -and $rangeEnd -gt 0) {
                            $buffer = [byte[]]::new($BufferSize)
                            $fileStream.Seek($rangeStart, 'Begin')
                            $bytesRead = $fileStream.Read($buffer, 0, $BufferSize)
                            $contentRange = "$RangeStart-$($RangeStart + $bytesRead - 1)/$($fileStream.Length)"

                            $response.StatusCode = 206
                            $response.ContentLength64 = $bytesRead
                            $response.Headers['Content-Range'] = $contentRange
                            $response.OutputStream.Write($buffer, 0, $bytesRead)
                            $response.OutputStream.Close()
                        }
                        else {
                            # if that stream has a content length
                            if ($result.ContentLength64 -gt 0) {
                                # set the content length
                                $response.ContentLength64 = $result.ContentLength64
                            }
                            # Then copy the stream to the response.
                            $fileStream.CopyTo($response.OutputStream)
                        }
                        $response.Close()
                        $fileStream.Close()
                        $fileStream.Dispose()
                    } -ThrottleLimit 100 -ArgumentList $result, $request, $response
                }
                else {
                    # otherwise, convert the result to JSON
                    # and set the content type to application/json if it is not already set
                    if (-not $response.ContentType) {
                        $response.ContentType = 'application/json'
                    }
                    $response.Close($outputEncoding.GetBytes((ConvertTo-Json -InputObject $result)), $false)
                }
                $duration = [DateTime]::Now - $event.TimeGenerated

                $elapsedText  = $duration.TotalMilliseconds.ToString('n0') + ' ms'
                $elapsedColor = ( $duration.TotalMilliseconds -gt 500 ) ? "${fg:red}" : ''

                Write-Host "Responded to $($request.Url) in ${duration} - ${elapsedColor}${elapsedText}" -ForegroundColor Cyan
                if( $PSHost ) {
                    @(
                        ' {0} {1} ' -f @(
                            $request.HttpMethod
                            $request.Url
                        )
                        ' Response: Status: {0}, ContentType: {1}' -f @(
                            $response.StatusDescription
                            $response.ContentType
                        )
                    )  | Write-Host -ForegroundColor Cyan

                }
            }
            else {
                $response.StatusCode = 404
                $response.ContentType = 'application/json'
                $body = @{
                    Error         = 'Endpoint not found'
                    RequestUrl    = $request.RawUrl
                    Query         = $request.QueryString
                    Method        = $request.HttpMethod
                    RequestHeader = $request.Headers
                } | ConvertTo-Json -Depth 3

                $buffer = [System.Text.Encoding]::UTF8.GetBytes( $body )
                $response.ContentLength64 = $buffer.Length
                $response.ContentEncoding = [System.Text.Encoding]::UTF8
                $response.OutputStream.Write( $buffer, 0, $buffer.Length )
                $response.Close()
            }
            $event | Remove-Event
        }
    }
}
#endregion Watch for events

function Start-RouteThread {
    <#
    .SYNOPSIS
        (internal function) ThreadJOb[s] that map and run routes. ( Called by Start-GitServe )
    .NOTES
        The public entrypoint to call this is through 'Server-Start'
    #>

    param(
        [Parameter()] [Runspace] $Runspace, # can param binding to default cause threadsafe issues, ie: is evaluated once, or before other lifetimes?

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [Alias('Listener')]
        [Net.HttpListener] $CurListener,
        # [hashtable] $Query = [ordered]@{}, Request.Url ParsedQuery String
        # [hashtable] $JobParams = [ordered]@{},
        [int] $ThrottleLimit = 50
    )
    $state = $Script:ModuleState
    $JobName =  'GitServe http://{0}:{1}/' -f @(
        $state.HostName
        $state.Port
    )

    if( -not $Runspace ) {
        $Runspace = [Runspace]::DefaultRunspace
        'Start-RouteThread: using default Runspace' | Write-warning
    }

    # Now we start our server in a thread job.
    # This lets us get requests in a background thread, and turn them into events.
    Start-ThreadJob -ScriptBlock {
        param(
            [Runspace] $MainRunspace,

            [Net.HttpListener] $Listener,
            $ThreadParams,

            $eventId = 'http'
        )
        while ( $Listener.IsListening ) {
            [Threading.Tasks.Task[System.Net.HttpListenerContext]] $nextRequest = $Listener.GetContextAsync()

            # while (-not ( $nextRequest.IsCompleted -or $nextRequest.IsFaulted -or $nextRequest.IsCanceled )) {
            # '.' | Write-Host -bg 'salmon' -NoNewline
            # # no-op?
            # }
            if ($nextRequest.IsFaulted) {
                Write-Error -Exception $nextRequest.Exception -Category ProtocolError
                "NextRequest.IsFaulted" | Write-Host -bg orange
                continue
            }
            $context = $(
                try { $nextRequest.Result }
                catch {
                    $msg = $_  | Join-String -op 'Error on nextRequest.Result! {0}'
                    $msg | write-warning
                    $msg | Get-Error | Write-Host -fg 'gray50' -bg 'gray30'
                    $_.ToString()
                }
            )
            if ($context.Request.Url -match '/favicon.ico$') {
                $context.Response.StatusCode = 404
                $context.Response.Close()
                continue
            }
            'Events.Generate()'
                | write-Host -bg 'salmon' -fg 'black'

            $eventArgs = @(
                $context,
                $context.Request,
                $context.Response
            )

            $extraData =  [Ordered]@{
                Url      = $context.Request.Url
                Context  = $context
                Request  = $context.Request
                Response = $context.Response
            }

            $MainRunspace.Events.GenerateEvent(
                <# sourceIdentifier: #> $eventId,
                <# sender: #> $Listener,
                <# args: #> $eventArgs,
                <# extraData: #> $extraData )

            # see also: Alternate overload:
            #
            # $MainRunspace.Events.GenerateEvent(
            # <# sourceIdentifier: #> $sourceIdentifier,
            # <# sender: #> $sender,
            # <# args: #> $args,
            # <# extraData: #> $extraData,
            # <# processInCurrentThread: #> $processInCurrentThread,
            # <# waitForCompletionInCurrentThread: #> $waitForCompletionInCurrentThread)
            # #>
        }
    } -Name $JobName -ArgumentList ( $Runspace, $CurListener, $ThreadParams ) -ThrottleLimit $ThrottleLimit
        | Add-Member -NotePropertyMembers (
            [Ordered]@{
                HttpListener = $CurListener
            }
        ) -PassThru
}


#endregion Private Module Functions


#region Public Functions

function Metric-GitServeCommitCount {
    <#
    .SYNOPSIS
        Number of commits grouped and sorted by: "<Year>-<Month>_<GitUserName>" as text Descending
    .NOTES
        Expects input type: 'git.log'
    .EXAMPLE
        git log | Metric-CommitCount
        git log | Metric-GitServeCommitCount -Period month
    .EXAMPLE
        Use-Git -GitArg 'log', '-n', 4, '-C', $path | GitServe.Metric.CommitCount
    #>

    [Alias('GitServe.Metric.CommitCount')]
    [OutputType(
        '[System.Collections.Generic.SortedDictionary[string,object]]'
    )]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [DateTime] $CommitDate,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string] $GitUserName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string] $GitUserEmail,

        # Input has 'ugit' properties like 'git.log'
        [Parameter(ValueFromPipeline)]
        [object] $InputObject,

        # Period to aggregate by: 'year' | 'month' | 'day'
        [ValidateSet('month', 'day', 'year')]
        [string] $Period = 'month',

        # ensure the date table is fully defined, even if missing.
        [Alias('WithBlankDates')]
        [ValidateScript({throw 'nyi'})]
        [switch] $IncludeMissingDates
    )
    begin {
        switch( $Period ) {
            'year' { $keyFormat  = 'yyyy'; $dateDisplayFormat = 'yyyy' }
            'month' { $keyFormat  = 'yyyy-MM'; $dateDisplayFormat = 'yyyy-MM' }
            'day' { $keyFormat  = 'yyyy-MM-dd'; $dateDisplayFormat = 'yyyy-MM-dd' }
            default { throw "Invalid Period! ${Period}" }
        }
        function __toKeyId {
            # Generate a PrimaryKey. This determines distinct testing for records
            param( $Obj )
            '{0}_{1}' -f @(
                $Obj.CommitDate.ToString( $keyFormat )
                $Obj.GitUserName
            )
        }
        $reverseComparer = [System.Collections.Generic.Comparer[string]]::Create({
            param($x, $y) [string]::Compare($y, $x)
        }) # todo: use numeric date sort
        [Collections.Generic.SortedDictionary[string,object]] $metric = $reverseComparer
    }
    process {
        $key = __toKeyId $InputObject
        if( -not $metric.ContainsKey( $key ) ) {
            $initialValue = [pscustomobject][ordered]@{
                PSTYpeName  = 'GitServe.Metric.CommitCount'
                DateDisplay  = $CommitDate.ToString( $dateDisplayFormat )
                GitUserName = $GitUserName
                CommitCount = 1
                Year        = $CommitDate.Year
                Month       = $CommitDate.Month
                CommitDate  = $CommitDate
                KeyId       = $key
            }
            $metric[ $key ] = $initialValue
        } else {
            $metric[ $key ].CommitCount += 1
        }
    }
    end {
        # if( $IncludeMissingDates ) {
        # # add any missing dates as explicit 0. Dates are based on the selected $period type. year/month/day/etc.
        # }
        ,@( $metric.Values )
    }
}

function Format-GitServeRelativePath {
    <#
    .synopsis
        Abbreviate a full path relative to another directory
    .example
        # Print paths relative the current Directory
        gci . -Depth 2 | Format-GitServeRelativePath
    .example
        > Get-Item 'c:\git\pwsh\SeeminglyScience'
            | Format-GitServeRelativePath 'c:\git'
 
        pwsh\SeeminglyScience
    #>

    [Alias('GitServe.Format-RelativePath')]
    [OutputType( [string] )]
    [CmdletBinding()]
    param(
        [Alias('BasePath')]
        [Parameter(Position = 0)]
        $RelativeTo = '.',

        # Strings / paths to convert
        [Alias('PSPath', 'FullName', 'InObj')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [string[]] $Path,

        # Emit an object with path properties, including the raw original path
        [Alias('PassThru')]
        [switch] $AsObject
    )
    process {
        $RelativeTo = Get-Item $RelativeTo
        foreach( $item in ( $Path | Convert-Path ) ) {
            $relPath = [System.IO.Path]::GetRelativePath(
                <# string: relativeTo #> $RelativeTo,
                <# string: path #>  $Item )

            if( -not $AsObject ) {
                $relPath
                continue
            } else {
                [pscustomobject]@{
                    PSTypeName = 'GitServe.RelativePath'
                    Path       = $relPath
                    Original   = $Item
                    RelativeTo = $RelativeTo
                }
                continue
            }
        }
    }
}


function _new-PaginationKey {
    <#
    .synopsis
        (internal) standard record shape for "Get-DatePaginationKey" return value
    .DESCRIPTION
        Used for git (log/shortlog) parameters and other pagination
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [datetime] $Since,

        [Parameter(Mandatory)]
        [datetime] $Until
    )

    [pscustomobject][ordered]@{
        PSTypeName   = 'GitServe.Date.PaginationKey'
        Since        = $Since
        Until        = $Until
        SinceDisplay = $Since.ToString('yyyy-MM-dd')
        UntilDisplay = $Until.ToString('yyyy-MM-dd')
    }
}
function Get-GitServeDatePaginationKey {
    <#
    .synopsis
        Get keys to paginate a date range, ex: for git log filters
    .DESCRIPTION
    .example
        > GitServe.Get-DatePaginationKey -StartDate (Get-Date)
    .example
        > GitServe.Get-DatePaginationKey -StartDate '2026-01-03'
        > GitServe.Get-DatePaginationKey -StartDate '2026-04-01'
 
        Since Until SinceDisplay UntilDisplay
        ----- ----- ------------ ------------
        2026-01-01 12:00:00 AM 2026-02-01 12:00:00 AM 2026-01-01 2026-02-01
        2026-04-01 12:00:00 AM 2026-05-01 12:00:00 AM 2026-04-01 2026-05-01
    .link
        GitServe.Get-NextDatePeriod
    .link
        GitServe.Get-DatePaginationKey
    #>

    [Alias('GitServe.Get-DatePaginationKey')]
    [OutputType( 'GitServe.Date.PaginationKey[]' )]
    [CmdletBinding()]
    param(
        # First date
        [Parameter(Mandatory)]
        [datetime] $StartDate,

        # Amount of time to add. 1 month is the default value. Values: ( 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' )
        [ValidateSet( 'year', 'month', 'week', 'day', 'hour', 'minute', 'second' )]
        [string] $Period = 'month',

        # Optional ending date. If set, this will return an array of all steps until the final one.
        # otherwise, ending is the default net first date step
        [Alias('UntilDate')]
        [Parameter()]
        [datetime] $MaxDate
    )
    [Collections.Generic.List[Object]] $allKeys = @()

    [datetime] $startDate_firstOfMonth = # first day of month of the input
        [datetime]::ParseExact(
            $StartDate.ToString('yyyy-MM-01'),
            'yyyy-MM-dd', ([cultureinfo]::GetCultureInfo('en-us')) )


    $startPeriod = $startDate_firstOfMonth
    while( $true ) {
        $splat_nextPeriod = @{
            CurrentDate = $startPeriod
            Period      = $Period
        }

        if( $null -ne $MaxDate ) {
            $splat_nextPeriod['MaxDate'] = $MaxDate
        }

        # initial value was: nextMonthDate_firstOfMonth
        $nextPeriod = GitServe.Get-NextDatePeriod @splat_nextPeriod
        if( $null -eq $nextPeriod ) {
            Write-Error "GitServe.Get-DatePaginationKey: Unhandled Period: ${period} ! StartPeriod: ${StartPeriod}, End: ${MaxDate}, StartDate: ${StartDate})"
            break
        }

        $splat_dates = @{
            Since = $startPeriod
            Until = $nextPeriod
        }
        $allKeys.Add(
            ( _new-PaginationKey @splat_dates )
        )
        $startPeriod = $nextPeriod
    }
    return ,$allKeys
}

function Get-GitServeNextDateForPeriod {
    <#
    .synopsis
        For a given time period, get the next closest date
    .link
        GitServe.Get-NextDatePeriod
    .link
        GitServe.Get-DatePaginationKey
    #>

    [Alias(
        'NextDateForPeriod',
        'GitServe.Get-NextDatePeriod'
    )]
    [CmdletBinding()]
    [OutputType( [datetime] )]
    param(
        # Relative this date
        [ValidateNotNull()]
        [Parameter(mandatory, position = 0 )]
        [datetime] $CurrentDate,

        # Amount of time to add: ( 'year' | 'month' | 'week' | 'day' | 'hour' | 'minute' | 'second' )
        [Parameter(Mandatory, position = 1)]
        [ValidateSet( 'year', 'month', 'week', 'day', 'hour', 'minute', 'second' )]
        [string] $Period = 'month',

        # optionally truncate max values when they go past MaxDate
        [Alias('UntilDate')]
        [Parameter(Position = 2)]
        [datetime] $MaxDate,

        # -Debug except it skips serialization when not being used
        [switch] $DebugInfo
    )

    $NextDate = switch( $Period ) {
        'year' {
            $CurrentDate.AddYears( 1 )
        }
        'month' {
            $CurrentDate.AddMonths( 1 )
        }
        'week' {
            $CurrentDate.AddDays( 7 )
        }
        'day' {
            $CurrentDate.AddDays( 1 )
        }
        'hour' {
            $CurrentDate.AddHours( 1 )
        }
        'minute' {
            $CurrentDate.AddMinutes( 1 )
        }
        'second' {
            $CurrentDate.AddSeconds( 1 )
        }
        default { throw "Unhandled $Period" }
    }

    # inputs are invalid, so throw
    if( $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('MaxDate') -and  $maxDate -le $CurrentDate ) {
        throw "Get-GitServeNextDateForPeriod: MaxDate cannot be less than initial CurDate! ( Max: ${MaxDate}, Current: ${CurrentDate} )"
    }

    # if nextDate is non-null, but still out of bounds: wrap within bounds
    # ( only when MaxDate was defined )
    if(
        $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('MaxDate') -and
        $nextDate -ge $maxDate
    ) {
        $nextDate = $maxDate # ie: [Math]::Min
    }

    if( ( $null -eq $NextDate ) -or $DebugInfo) {
    # $NextDate | Write-Host -bg blue
        @{
            Current = $CurrentDate
            Next    = $NextDate
            Max     = $MaxDate
            Period  = $Period
        } | ConvertTo-Json -Compress | Write-Debug -Debug
    }
    # if null, do not return a key. but do not throw since inputs were valid.
    if( $null -eq $NextDate ) {
        # Write-Warning 'Get-GitServeNextDateForPeriod: NextDate was null for input'
        return $null
    }


    # all conditions are valid
    return $nextDate
}

function GetConfig.ClonedRepoRoot {
    <#
    .synopsis
        Get app configuration for root directories to search ( ie: local, vs docker, etc )
    .DESCRIPTION
        Get root directories for cloned repos.
    #>

    [Alias('GitServe.Get-ConfigRepoRoot')]
    [OutputType( [System.IO.DirectoryInfo[]] )]
    [CmdletBinding()]
    param(
        # Always return the first match. Default is to return all
        [Alias('LimitOne')]
        [switch] $FirstOnly
    )

    $rootPaths = @(
        $script:ModuleState.ClonedRepoRoot
        | Where-Object { Test-Path $_ } | Get-Item -ea ignore
    )

    if( $First ) {
        return $rootPaths | Select-Object -First 1
    }
    return $rootPaths
}

function GetConfig.Host {
    <#
    .synopsis
        Get app configuration for root directories to search ( ie: local, vs docker, etc )
    .description
    EnvVars have priority, else fall back to defaults.
 
        GITSERVE_PORT = 3001
        GITSERVE_HOST = 127.0.0.1 # or '*' when using docker
 
    .DESCRIPTION
        Get Url for Host, Port, Authority, etc
    #>

    [Alias('GitServe.Get-ConfigHost')]
    [CmdletBinding()]
    param()

    $Port     = $Env:GITSERVE_PORT ?? 3001
    $HostName = $Env:GITSERVE_HOST ?? '127.0.0.1'
    $Url      = "http://${HostName}:${Port}"

    [pscustomobject]@{
        PSTypeName = 'GitServe.Config.Host'
        Host       = $HostName
        Port       = $Port
        Url        = $Url                    # ie: UriPartial::Authority
    }
}

# function Invoke-GitClone {
function Invoke-GitServeClone {
    <#
    .synopsis
        Clone a public git repo using the 'git' cli ( without using 'gh' )
    .example
        > GitServe.Git.Clone 'https://github.com/BurntSushi/ripgrep.git'
    #>

    [Alias(
        'GitServe.Git.Clone'
    )]
    [CmdletBinding( DefaultParameterSetName = 'CloneFromRawUrl' )]
    param(
        # Git url to clone
        [Parameter( ParameterSetName = 'CloneFromRawUrl', Position = 0, Mandatory )]
        [Alias( 'Repo', 'Clone', 'Url', 'GitUrl')]
        [ArgumentCompletions(
            'https://github.com/BurntSushi/ripgrep.git'
        )]
        [string] $CloneUrl, # ( string because not all valid git clone urls are valid,

        [string] $FromPath = '.'
    )
    end {
        "enter => '$( $MyInvocation.MyCommand.Name )'" | Write-Debug
        InvokeCli.Git.CloneRepo -CloneUrl $CloneUrl -FromPath $FromPath -PSHost:$True
    }
    # [pscustomobject]@{
    # PSTypeName = 'GitServe.Git.Clone'
    # CloneUrl = $CloneUrl
    # Result = '<NYI>'
    # }
}


# Cache the binary lookup because it's slow
$script:BinRealGit = Get-Command -CommandType Application -Name 'git' -ea 'Continue' -TotalCount 1

function Invoke-GitServeUGit {
    <#
    .synopsis
        always invokes UGit command
    .notes
        Use-Git requires -C param to be last, rather than first. ie:
 
            > Use-Git -GitArgument @( 'log', '-n', '3', '-C', (gi '.' ) )
    .example
        # RealGit vs UGit, same syntax:
        > GitServe.Invoke-UGit -FromPath (gi .) -GitArgList 'log', '-n', '3'
        > GitServe.Invoke-RealGit -FromPath (gi .) -GitArgList 'log', '-n', '3'
    .example
        GitServe.Invoke-Ugit -FromPath '..\GitServed\' status
        GitServe.Invoke-UGit log, -n, 2
    .link
        Invoke-GitServeRealGit
    .link
        Invoke-GitServeUGit
    #>

    [Alias(
        'GitServe.Invoke-UGit'
    )]
    [CmdletBinding()]
    param(
        # Arguments passed to real 'git'. Or any not configurable from the other parameters
        [Alias('ArgList', 'GitArgs', 'RealGitArgs')]
        [string[]] $GitArgList,

        # What path will you execute from? This saves you the overhead of changing directories
        [Alias('Path', 'PSPath', 'GitRepositoryPath', 'RepoPath')]
        [Parameter()]
        [string] $FromPath, # = '.',

        # view the commandline that *would* be ran, but don't actually run it.
        [Alias('TestOnly', 'WhatIf')]
        [switch] $DryRun,

        # for git argument: '--since=<string>'
        [string] $Since,

        # for git argument: '--before=<string>'
        [string] $Before,

        # for git argument: '--after=<string>'
        [string] $After,

        # Like -DryRun but returns the arguments instead of printing them
        [Alias('PassThru')]
        [switch] $OutputArgAsList,

        # Write to host
        [Alias('VerboseOutput')]
        [switch] $PSHost
    )
    begin {
        #region collect UGit args
        $binGit = $script:BinRealGit
        [Collections.Generic.List[Object]] $gitArgs = @()
        if( $GitArgList.count -gt 0 ) {
            # any extra parsing or filtering of user args?
            $gitArgs.AddRange( @( $GitArgList ) )
        }
        if( -not [string]::IsNullOrWhiteSpace( $Since ) ) {
            $gitArgs.Add( ( '--since="{0}"' -f $Since ))
        }
        if( -not [string]::IsNullOrWhiteSpace( $Before ) ) {
            $gitArgs.Add( ( '--before="{0}"' -f $Before ))
        }
        if( -not [string]::IsNullOrWhiteSpace( $After ) ) {
            $gitArgs.Add( ( '--after="{0}"' -f $After ))
        }
        if( $PSBoundParameters.ContainsKey('FromPath') ) {
            <#
            ugit warning: '-C' must be placed in the correct position or else you get a silent error
            It must be the *very last*, which is the incorrect position for RealGit
                GitServe.Invoke-UGit '-C', (gi '.' ), log # Silently fails, returning error objects
                GitServe.Invoke-UGit log, '-C', (gi '.') # working query
            #>

            $absolutePath = Get-Item $FromPath -ea 'stop'
            $gitArgs.AddRange( @('-C', $absolutePath.FullName ) )
        }
        #endregion collect UGit args
    }
    process { }
    end {
        #region invoke UseGit
        "enter => '$( $MyInvocation.MyCommand.Name )'" | Write-Debug

        if( $OutputArgAsList ) {
            return @( $GitArgs )
        }
        if( $DryRun ) {
            $gitArgs
            | Join-String -sep ' ' -op 'Calling UseGit => git '
            | Write-host -fg 'SlateGray'
            return
        }

        # option to always log to host
        if( $PSHost ) {
            $gitArgs
            | Join-String -sep ' ' -op ' UseGit => git '
        }
        $gitArgs
            | Join-String -sep ' ' -op 'Calling UseGit => git '
            | Write-Debug

        Use-Git -GitArgument $gitArgs
        #endregion invoke UseGit
    }
}
function Invoke-GitServeRealGit {
    <#
    .synopsis
        always invokes native/real git
    .NOTES
        future includes an ignore redirect like 2>$null ? Or move that to a special command that invokes this
    .example
        # to run: git --no-pager log -n 4 --format=oneline --color=always
        GitServe.Invoke-RealGit --no-pager, log, -n, 4, --format=oneline, --color=always
 
        # or the same using cmdlet parameters
        GitServe.Invoke-RealGit -NoPager -ColorAlways log, -n, 4, --format=oneline
    .example
        # setting custom output format
        GitServe.Invoke-RealGit -Format oneline log, -n, 4, --abbrev-commit, --after=2022-12-01
    .example
        # DryRun: Do not actually invoke git. Just print the arguments that would be
        > GitServe.Invoke-RealGit -DryRun -FromPath 'C:\data\myGit\GitServed' -ArgList 'log', '-n', '2'
    .example
        > GitServe.Invoke-RealGit -FromPath 'C:\data\myGit\GitServed' -ArgList 'log', '-n', '2'
    .example
        # example: list HEAD files
        # the original command was: git.exe -C (gi '.') ls-tree -r HEAD --name-only
        GitServe.Invoke-RealGit -Path '.' -GitArgList 'ls-tree', '-r', 'HEAD', '--name-only'
    .example
        # show tags, jump to tag
        GitServe.Invoke-RealGit -ColorAlways -NoPager tag, -n
        # out: v0.0.12
        GitServe.Invoke-RealGit -ColorAlways -NoPager show, v0.0.12
 
        # show hash and message since tag
        GitServe.Invoke-RealGit -ColorAlways -NoPager log, v0.0.12..HEAD, --oneline
 
        # show commit message only since tag
        GitServe.Invoke-RealGit -ColorAlways -NoPager log, v0.0.12..HEAD, --format=%s
    .link
        Invoke-GitServeRealGit
    .link
        Invoke-GitServeUGit
    #>

    [Alias(
        'GitServe.Invoke-RealGit'
    )]
    [CmdletBinding()]
    param(
        # Arguments passed to real 'git'. Or any not configurable from the other parameters
        [Alias('ArgList', 'GitArgs', 'RealGitArgs')]
        [string[]] $GitArgList,

        # What path will you execute from? This saves you the overhead of changing directories
        [Alias('Path', 'PSPath', 'GitRepositoryPath', 'RepoPath')]
        [Parameter()]
        [string] $FromPath, # = '.',

        # view the commandline that *would* be ran, but don't actually run it.
        [Alias('TestOnly', 'WhatIf')]
        [switch] $DryRun,

        # for git argument: '--since=<string>'
        [string] $Since,

        # for git argument: '--before=<string>'
        [string] $Before,

        # for git argument: '--after=<string>'
        [string] $After,

        # for git argument: --no-pager
        # depending on the command, and if you're running as jobs, this may or may not matter ( at least when ran in non-interactive mode )
        [switch] $NoPager,

        # always output ansi escapes: for git argument: --color=always
        [switch] $ColorAlways,

        # for git argument: --format=<format>:
        <#
        note(valid): valid formats for 'git log':
            one of oneline, short, medium, full, fuller, reference, email, raw, format:<string> and tformat:<string>.
            When <format> is none of the above, and has %placeholder in it, it acts as if --pretty=tformat:<format> were given.
        #>

        [ArgumentCompletions('oneline', 'short', 'medium', 'full', 'fuller', 'reference', 'email', 'raw'  )]
        [string] $Format,

        # Like -DryRun but returns the arguments instead of printing them
        [Alias('PassThru')]
        [switch] $OutputArgAsList,

        # Write to host
        [Alias('VerboseOutput')]
        [switch] $PSHost
    )
    begin {
        #region collect RealGit args
        $binGit = $script:BinRealGit
        [Collections.Generic.List[Object]] $gitArgs = @()

        #region args before -C
        if( $NoPager ) {
            $gitArgs.Add('--no-pager')
        }
        #endregion args before -C

        if( $PSBoundParameters.ContainsKey('FromPath')) {
            # note: real git requires '-C' before almost all args.
            $absolutePath = Get-Item $FromPath -ea 'stop'
            $gitArgs.AddRange( @('-C', $absolutePath.FullName ) )
        }

        if( $GitArgList.count -gt 0 ) {
            # any extra parsing or filtering of user args?
            $gitArgs.AddRange( @( $GitArgList ) )
        }

        if( -not [string]::IsNullOrWhiteSpace( $Since ) ) {
            $gitArgs.Add( ( '--since="{0}"' -f $Since ))
        }
        if( -not [string]::IsNullOrWhiteSpace( $Before ) ) {
            $gitArgs.Add( ( '--before="{0}"' -f $Before ))
        }
        if( -not [string]::IsNullOrWhiteSpace( $After ) ) {
            $gitArgs.Add( ( '--after="{0}"' -f $After ))
        }

        #region args after all other git args
        if( $Format ) {
            $gitArgs.Add( "--format=${Format}" )
        }
        if( $ColorAlways ) {
            $gitArgs.Add( '--color=always' )
        }
        #endregion args after all other git args
        #endregion collect RealGit args
    }
    process { }
    end {
        #region invoke RealGit
        "enter => '$( $MyInvocation.MyCommand.Name )'" | Write-Debug

        if( $OutputArgAsList ) {
            return @( $GitArgs )
        }
        if( $DryRun ) {
            $gitArgs
            | Join-String -sep ' ' -op 'Calling RealGit => git '
            | Write-host -fg 'SlateGray'

            return
        }

        # option to always log to host
        if( $PSHost ) {
            $gitArgs
            | Join-String -sep ' ' -op ' RealGit => git '
        }
        $gitArgs
            | Join-String -sep ' ' -op 'Calling RealGit => git '
            | Write-Debug

        $results = & $binGit @gitArgs
        $results
        # captures and emit so that the future is easily cache-able, and may redirect stderr to null
        #endregion invoke RealGit
    }
}

function Metric-GitServeLanguageCount {
    <#
    .SYNOPSIS
        Which languages are used in a repo
    .NOTES
        Expects input type: 'git.log'
    .EXAMPLE
        git log | Metric-LanguageCount
        git log | Metric-GitServeLanguageCount -Period month
    .EXAMPLE
        GitServe.Metric.LanguageCount -Path '.'
    #>

    [Alias('GitServe.Metric.LangaugeCount')]
    [OutputType(
        '[System.Collections.Generic.SortedDictionary[string,object]]'
    )]
    [CmdletBinding()]
    param(
        # Path of git repo
        [Parameter(Mandatory)]
        [Alias('BaseDir', 'Repository', 'Path')]
        [string] $GitRepositoryPath
    )
    begin {
    }
    process {
    }
    end {
        $results = InvokeCli.Git.LsTree.Files -GitRepositoryPath $GitRepositoryPath
        # note: slow because of the provider, but,
        $instances = $results | %{
            $_ -replace '.*/', '' -replace '.*\.', ''
        }
        $found = $instances | Group-Object -NoElement | Sort count -Descending
        # $found = ($instances | Get-item | % Extension ) | Group-Object -NoElement | Sort count -Descending

        $summary = $found.GetEnumerator() | %{
            $extension = $_.Name -replace '^\.'
            $count    = $_.Count
            [pscustomobject][ordered]@{
                PSTYpeName = 'GitServe.Metric.LanguageCount'
                Extension  = $extension
                Count      = $count
                # KeyId = $extension
            }
        }
        , @( $summary )
    }
}

function Get-GitServeRepoList {
    <#
    .synopsis
        List git repos. Same as: irm /repo/list
    .DESCRIPTION
    .notes
    .example
        # using cache
        GitServe.Repo.List
    .example
        # force updating the repo listing
        GitServe.Repo.List -WithoutCache
    .example
        # you can increase depth but this will increase time
        GitServe.Repo.List -MaxDepth 6
    #>

    [Alias('GitServe.Repo.List')]
    [OutputType( 'GitServe.Route.Repo.List' )]
    [CmdletBinding()]
    param(
        # Force refresh, clear repo listing cache. Saves result to cache file.
        [Alias('Force')]
        [switch] $WithoutCache,

        # Max depth for Get-ChildItem to search? The majority of time spent is from this setting. ( default: 4 )
        [int] $MaxDepth = 4
    )

    $searchRoot = @( GetConfig.ClonedRepoRoot )
    $findGitRepos = Get-ChildItem $searchRoot -Filter '.git' -Directory -Force -Depth $MaxDepth | ForEach-Object Parent
    $delim = "`u{2400}" # unique, but safe to print delimiter

    $outputTypeName = 'GitServe.Route.Repo.List'

    #region load cache
    $JsonCachePath = $script:ModuleState.JsonCacheRepoList
    $cache = $null
    [Collections.Generic.List[object]] $records = @()

    if( $WithoutCache ) {
        '$WithoutCache, deleting: ' | Write-Verbose
        Remove-Item -LiteralPath $JsonCachePath
    }

    if( -not $WithoutCache ) {
        $JsonCachePath | Join-String -op 'Using Cache: ' | Write-Verbose
        $cache = Get-Content $JsonCachePath -ea ignore | ConvertFrom-Json
        $cache.RepoList.Count | Join-String -op '$cache.RepoList.Count: ' | Write-Verbose
        # if valid results, emit correct type names
        if( $cache.RepoList ) {
            $records = $cache.RepoList | %{
                $_.PSObject.TypeNames.Insert(0, $outputTypeName )
                $_
            }
        }
    }
    #endregion load cache

    $records.count | Join-String -op 'records loaded records from cache? ' | Write-Verbose

    if( $WithoutCache -or ( $records.count -eq 0 ) ) {
        #region calculate new return value
        # cache was either invalid or was disabled
        'Calculating fresh repo list' | Write-Verbose

        $records = @(
            foreach ($repoPath in $findGitRepos) {
                # get remote, or fallback string
                $remote = ( ( GitServe.Invoke-RealGit -FromPath $repoPath.FullName -GitArgList 'remote', 'get-url', 'origin'  ) 2>$Null ) ?? '<empty-remote>'

                # Grab latest commit date and relative using a single git call. Then split by delim.

                $delim = "`u{2400}"
                $fStr = "--format=%cr${delim}%cd"
                $out = GitServe.Invoke-RealGit -FromPath $repoPath.FullName -GitArgList @(
                    'log', '-n', '1',
                    $fStr, '--date=format:%Y-%m-%d'
                )
                $newestCommitRelative, $newestCommitDateOnly = $out -split $delim, 2
                $ownerPathName = $repoPath.FullName | Split-path -Parent | split-path  -Leaf

                [pscustomobject][ordered]@{
                    PSTypeName           = 'GitServe.Route.Repo.List'
                    # CommitCount = $commitCount # disabled(slow): commit count
                    Name                 = $repoPath.BaseName
                    NewestCommitDate     = $newestCommitDateOnly
                    NewestCommitRelative = $newestCommitRelative
                    Owner                = $ownerPathName
                    OwnerRepoPair            = '{0}/{1}' -f @( $ownerPathName, $repoPath.BaseName )
                    Path                 = $repoPath.FullName
                    Remote               = $remote
                    # '( git remote get-url origin 2>$null | out-null ) ?? '<missing>''
                }
            }
        )
        #endregion calculate new return value
    }

    $records.count | Join-String -op 'final $records.count: ' | Write-Verbose

    # save cache if any records are found
    if( $records.count -gt 0  ) {
        $JsonCachePath | Join-String -op 'Writing: ' | Write-Verbose
        @{
            LastUpdate = [datetime]::Now
            RepoList = @( $records )
        }   | ConvertTo-Json
            | Set-Content -Encoding utf8 -Path $JsonCachePath
    }

    # Always remove file if records are empty
    if( $records.count -eq 0 ) {
        $JsonCachePath | Join-String -op 'Records count == 0, deleting: ' | Write-Verbose
        Remove-Item -LiteralPath $JsonCachePath
    }


    return $records
}

function ConvertFrom-GitServeShortRepoName {
    <#
    .synopsis
        Resolve a valid directory path using relative repo names
    .DESCRIPTION
        Exceptions - Does not throw unless you opt in. Default behavior is to write error and returns $Null.
 
        -Throw : always throw when path is missing. Opposite of NeverThrow
    .notes
        should it throw an exception if more than one repo matches the name?
        should it throw if none is found? Or write error and return null?
    .example
        > $optional = GitServe.Path.FromShortRepoName -Name 'BurntSushi/ripgrep'
        > $required = GitServe.Path.FromShortRepoName -Name 'BurntSushi/ripgrep' -Throw
    #>

    [Alias('GitServe.Path.FromShortRepoName')]
    [OutputType(
        [System.IO.DirectoryInfo]
    )]
    [CmdletBinding()]
    param(
        # ex: "BurntSushi/ripgrep" . ( see "OwnerRepoPair" from /repo/list )
        [Alias('Name', 'RepoOwnerPair' )]
        [Parameter(Mandatory)]
        [string] $ShortRepoName,

        # Base directory to search. Default is from: GetConfig.ClonedRepoRoot
        [Alias('ClonedRepoRoot', 'RelativeRoot', 'Root', 'Path')]
        [string] $BasePath,

        # opt-in to throwing on missing paths ( Does not throw unless you opt in. Default behavior is to write error and returns $Null )
        [switch] $Throw
    )

    if ( [String]::IsNullOrWhitespace( $BasePath ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'RootPath: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }
    $RepoPath = Join-Path $ClonedRepoRoot $ShortRepoName # todo(sanitization): use a better escape and match method
    if( ! ( Test-Path $RepoPath )) {
        $ErrorMsg = "Error: Invalid ShortRepoName! '${ShortRepoName}'"
        if( $Throw ) { throw $ErrorMsg }

        $errorMsg | Write-Error
        return # or throw "Error: Invalid ShortRepoName! '${ShortRepoName}'"
    }
    return (Get-Item $RepoPath)
}

function Start-GitServe {
    <#
    .synopsis
        Start listen server
    .DESCRIPTION
        Main entry point for the user
    .notes
        - calls Stop-GitServe if listener is active
        - aliased as Start-GitServe, GitServe.Start
    .example
        # default uses random ports on localhost:
        > Start-GitServe
        # or as an alias:
        # GitServe.Start
    .example
        > GitServe.Start -Host $ip -Port $port
    .LINK
        Start-GitServe
    .LINK
        Stop-GitServe
    .LINK
        GitServe
    #>

    [Alias('GitServe.Start')]
    [CmdletBinding()]
    param(
        [ArgumentCompletions(
            "'127.0.0.1'", "'*'", "'localhost'" # "'0.0.0.0'", "'*'", "'localhost'"
        )]
        [Alias('Ip')]
        [Parameter(Position = 0)]
        [String] $HostName = '127.0.0.1',

        [Parameter()]
        [int] $Port,

        # set: State.CorsAllowOrigin
        [Parameter()]
        [string[]] $CorsAllowOrigin = @('*'),

        # set: State.CorsAllowMethods
        [Parameter()]
        [string] $CorsAllowMethods = 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD',

        # set: State.CorsAllowHeaders
        [Parameter()]
        [string] $CorsAllowHeaders = 'Content-Type, Authorization, X-Requested-With',

        # set: State.CorsAllowCredentials
        [Parameter()]
        [switch] $CorsAllowCredentials,

        # log request debug info to the console
        [Alias('DebugInfo')]
        [switch] $PSHost
    )
    # if( $Script:Listener.IsListening ) {
    # Stop-GitServe
    # }
    $state = $Script:ModuleState
    if( $null -eq $Script:Listener ) {
        $Script:Listener = [Net.HttpListener]::new()
    }
    [Net.HttpListener] $curListener = $Script:Listener ?? [Net.HttpListener]::new()


    if( -not $Port ) { $Port = Get-Random -Minimum 3000 -Maximum 4000 }
    $state.HostName = $HostName
    $state.Port = $Port
    $state.CorsAllowOrigin = @($CorsAllowOrigin)
    $state.CorsAllowMethods = $CorsAllowMethods
    $state.CorsAllowHeaders = $CorsAllowHeaders
    $state.CorsAllowCredentials = [bool] $CorsAllowCredentials
    $prefix = 'http://{0}:{1}/' -f @(
        $state.HostName
        $state.Port
    )
    if( $null -eq $curListener ) {
        throw "Listener was null!"
    }
    $curListener.Prefixes.Add( $prefix )

    # foreach( $curPrefix in $prefix ) {
    # }
    if( $PSHost ) {
        $prefix
            | Join-String -f ' prefix {0}' | Write-Host -fg 'gray50'
        $curListener.Prefixes
            | Join-String -f ' add prefix {0}' | Write-Host -fg 'gray50'
    }

    try {
        $curListener.Start()
    } catch [Net.HttpListenerException] {
        # if $_ -match 'failed to listen on prefix.*existing registration'
        'Error, is port in use?' | Write-Error -ErrorId 'Start-GitServe.PortInUse' -Category ResourceExists
        $Script:Listener = $null
        return
    }

    "$( (Get-Date).ToString('u')) GitServe: started listening on: http://$( $state.HostName ):$( $state.Port ))"
        | Write-Host

    "Start-GitServe: <ctrl+c> to stop server" | Write-Host -fg darkblue

    $startRouteThreadSplat = @{
        Runspace      = [runspace]::DefaultRunspace
        Listener      = $curListener
        ThrottleLimit = 50
    }

    Start-RouteThread @startRouteThreadSplat
        | Write-Debug

    $startListenLoopSplat = @{
        Listener = $Script:Listener
        PSHost = $PSHost
    }
    Start-ListenLoop @startListenLoopSplat
}

function Stop-GitServe {  # 'Stop-GitServeServer' sounded bad
    <#
    .synopsis
        Stop listen server. Dispose of HttpListener and ThreadJobs
    .example
        > GitServe Stop
    .LINK
        Start-GitServe
    .LINK
        Stop-GitServe
    .LINK
        GitServe
    .LINK
        https://learn.microsoft.com/en-us/dotnet/api/system.net.httplistener?view=net-10.0
    #>

    [Alias('GitServe.Stop')]
    [CmdletBinding()]
    param()

    [Net.HttpListener] $list = $Script:Listener
    # Close HttpListener first so route jobs unblock from GetContextAsync().
    if( $null -ne $List ) {
        if( $List.IsListening ) {
            "$( (Get-Date).ToString('u')) GitServe: Stopped listening" | Write-Host
            $List.Stop()
        }
        $List.Close()
        $Script:Listener = $null
    }

    Get-Job -State Completed | ? Name -match 'GitServe.*' | Remove-Job

    $threadJobs = @( Get-Job | Where-Object Name -Match 'GitServe.*' )
    if( $threadJobs.Count -gt 0 ) {
        $threadJobs.Name
            | Join-String -sep ', ' -SingleQuote -op 'GitServe Jobs: ' -os '. Stopping...'
            | Write-Warning

        $threadJobs | Stop-Job -ErrorAction Continue
        $threadJobs | Remove-Job -Force -ErrorAction Continue
    }
}

function SetConfig.ClonedRepoRoot {
    <#
    .synopsis
        Set app configuration for root directories to search ( ie: local, vs docker, etc )
    .DESCRIPTION
        Set root directories for cloned repos.
    #>

    [Alias('GitServe.Set-ConfigRepoRoot')]
    [CmdletBinding()]
    param(
        # A list of root directories to search for git repos
        [Alias('RootDirectory')]
        [object[]] $Path
    )

    $script:ModuleState.ClonedRepoRoot = @( $Path )

    # Clear cached repos since the path[s] have changed
    Clear-ResponseCacheKey -Key '/repo/list' -Verbose:$false
}

function /repo/author {
    <#
    .SYNOPSIS
        Return distinct list of authors in a time period
    .DESCRIPTION
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
        since - "2.months"
        after - '2024-01-01'
        before - '2024-01-01'
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/author?name=BurntSushi/ripgrep&period=2.months'
    #>

    [OutputType( 'GitServe.Route.Repo.Author' )]
    [Alias('GitServe.Route.Author')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request

    )
    $endpointLabel = '/repo/author'
    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request

    [string] $OwnerRepoPair = $parsedQuery.Get('name')
    [bool] $Using_ByEmail   = $parsedQuery.Get('ByEmail') ??  $false
    [string] $Period        = $parsedQuery.Get('period') ?? 'year'

    #region Build Git Args
    $RepoPath = GitServe.Path.FromShortRepoName -ShortRepoName $OwnerRepoPair -Throw

    [Collections.Generic.List[object]] $gitArgs = @(
        'log'
        if( $Using_ByEmail ) {
            '--format="%ae"' # if email
        } else {
            '--format="%an"'
        }
    )

    $RealGit_splat = @{
        FromPath = $RepoPath
        GitArgList = $gitArgs
    }
    if( $parsedQuery.Get('since') ) {
        $RealGit_splat['since'] = $parsedQuery.Get('since')
    }
    if( $parsedQuery.Get('before') ) {
        $RealGit_splat['before'] = $parsedQuery.Get('before')
    }
    if( $parsedQuery.Get('after') ) {
        $RealGit_splat['after'] = $parsedQuery.Get('after')
    }
    write-warning 'WIP: requires Metric-GitServeCommitCount'

    #endregion Build Git Args
    #region Invoke Git Args
    try {
        $ErrorActionPreference = 'stop' # wip: fix this route
        # $SelectProperty =

        [object[]] $results = Invoke-GitServeRealGit @RealGit_splat
            | Sort-Object -Unique
            # | Select-Object -Property $SelectProperty
            | GitServe.Metric.CommitCount -Period $Period
    }
    catch {
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Host
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Error
    }
    finally {
        $ErrorActionPreference = 'continue'
    }

    return [pscustomobject]@{
        PSTypeName = 'GitServe.Route.Repo.Author'
        Authors = , @( $results )

    }
    # return ,$results
    #endregion Invoke Git Args
}

function /cache/clear {
    <#
    .synopsis
        Clear all caches
    #>

    [OutputType( 'GitServe.Route.Debug.ClearCache' )]
    param()

    /cache/request/clear
}
function /cache/request/clear {
    <#
    .SYNOPSIS
        Debug. Clears RequestCache
    .description
        Clears module variable 'Script:ResponseCache'
    #>

    [OutputType( 'GitServe.Route.Debug.ClearCache' )]
    param()

    $cache = $Script:ResponseCache
    'Removing {0} keys' -f ( $cache.Keys.count ) | Write-Host -fore Cyan
    $cache.clear()

    [pscustomobject][ordered]@{
        PSTypeName = 'GitServe.Route.Debug.ClearCache'
        Message    = 'RequestCache cleared'
        Now        = [Datetime]::Now
    }
}

function /repo/clone {
    <#
    .SYNOPSIS
        Clone a repository to the docker volume
    .Description
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/Clone?url=https://github.com/BurntSushi/ripgrep.git'
    .NOTES
        Response is not explicitly cached
    #>

    [OutputType( 'GitServe.Route.Repo.Clone' )]
    param(
        [object] $Request
    )

    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request

    $Request | ConvertTo-Json -depth 1 -wa ignore | Write-Debug

    [string] $gitUrl = @( $parsedQuery.GetValues('url') )[0]

    InvokeCli.Git.CloneRepo -Url $gitUrl -path (GetConfig.ClonedRepoRoot -First)
        | Write-Debug

    Clear-ResponseCacheKey -Key '/repo/list' -Verbose:$false

    [pscustomobject]@{
        PSTypeName         = 'GitServe.Route.Repo.Clone'
        Query              = $request.Url.PathAndQuery
        CloneUrl           = $gitUrl
        # DebugRequest = $Request
    }

}

function /repo/metric/commit {
    <#
    .SYNOPSIS
        Number of commits grouped and sorted by: "<Year>-<Month>_<GitUserName>" as text Descending
    .DESCRIPTION
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
        since - "2.months"
        after - '2024-01-01'
        before - '2024-01-01'
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep'
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&period=month'
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&period=day'
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&period=year'
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&since=2.months'
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&after=2024-01-01'
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=BurntSushi/ripgrep&before=2026-01-01'
    .example
        # multiple filters
        irm 'http://127.0.0.1:3001/repo/metric/commit?name=startautomating/ezout&after=2024-01-01&before=2024-09-04'
    .EXAMPLE
    .LINK
        GitServe\Metric-GitServeCommitCount
    #>

    [OutputType( 'GitServe.Route.Repo.Metric.Commit' )]
    [Alias('GitServe.Route.Metric.Commit')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request

    )
    $endpointLabel = '/repo/metric/commit'
    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request

    [string] $OwnerRepoPair = $parsedQuery.Get('name')
    [string] $Period = $parsedQuery.Get('period') ?? 'year'

    if ( [String]::IsNullOrWhitespace( $ClonedRepoRoot ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'RootPath: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }

    #region Build Git Args
    $RepoPath = Join-Path $ClonedRepoRoot $OwnerRepoPair # todo(sanitization): use a better escape and match method
    if( ! ( Test-Path $RepoPath )) {
        "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'" | Write-Host -fore red
        throw "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'"
    }
    [Collections.Generic.List[object]] $gitArgs = @(
        'log'
    )

    $SelectProperty = 'CommitDate', 'GitUserName', 'Date', 'Scope', 'CommitType', 'Merged', 'CommitHash', 'Trailer', 'Trailers'
    $UGit_splat = @{
        FromPath = $RepoPath
        GitArgList = $gitArgs
    }
    if( $parsedQuery.Get('since') ) {
        $UGit_splat['since'] = $parsedQuery.Get('since')
    }
    if( $parsedQuery.Get('before') ) {
        $UGit_splat['before'] = $parsedQuery.Get('before')
    }
    if( $parsedQuery.Get('after') ) {
        $UGit_splat['after'] = $parsedQuery.Get('after')
    }

    #endregion Build Git Args
    #region Invoke Git Args
    try {

        [object[]] $results = Invoke-GitServeUGit @UGit_splat
            | Select-Object -Property $SelectProperty
            | GitServe.Metric.CommitCount -Period $Period
    }
    catch {
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Host
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Error
    }
    finally { }


    return ,$results
    #endregion Invoke Git Args
}

function / {
    # This demo will be a lot of randomly generated content, so we'll set a random refresh rate
    # variable context is shared between functions, so other animations can know the ideal timeframe to use.

    # The refresh interval is the only dynamic part of this page.
    # $Html = '<h1>Docker</h1><p>Now: {0}</p>' -f ( Get-Date )

    [string] $Html = "<h1 style='text-align:center'> Responded in $( ([DateTime]::Now - $event.TimeGenerated) )</h1>"
    New-HtmlTemplate -Title 'Index' -HtmlContent $Html
}

function /repo/metric/language {
    <#
    .SYNOPSIS
        Get count of file extensions in HEAD
    .DESCRIPTION
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/metric/language?name=BurntSushi/ripgrep'
    .EXAMPLE
    .LINK
        GitServe\Metric-GitServeLanguageCount
    #>

    [OutputType( 'GitServe.Route.Repo.Metric.Commit' )]
    [Alias('GitServe.Route.Metric.Language')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request
    )
    $endpointLabel = '/repo/metric/language'

    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request
    [string] $OwnerRepoPair = $parsedQuery.Get('name')

    if ( [String]::IsNullOrWhitespace( $ClonedRepoRoot ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'RootPath: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }
    $RepoPath = Join-Path $ClonedRepoRoot $OwnerRepoPair # todo(sanitization): use a better escape and match method
    if( ! ( Test-Path $RepoPath )) {
        "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'" | Write-Host -fore red
        throw "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'"
    }

    #region Invoke Git Args
    $results = Metric-GitServeLanguageCount -GitRepositoryPath $RepoPath
    return ,$results
    #endregion Invoke Git Args
}

function /cache/list {
    <#
    .SYNOPSIS
        Debug. Displays metadata on cached responses
    .description
        Basic info on the state of '$Script:ResponseCache'
    #>

    [OutputType( 'GitServe.Route.Cache.List' )]
    param()
    $cache = $Script:ResponseCache
    ,@( $cache.GetEnumerator() | %{
        [pscustomobject][ordered]@{
            PSTypeName = 'GitServe.Route.Cache.List'
            Key        = $_.Key
            ValueType  = $_.Value | % GetType | % Name | Sort-Object -unique | Join-String -sep ', '
        }
    })
}

function /repo/list {
    <#
    .SYNOPSIS
        Return user's cloned repos. Cached.
    .description
 
    .NOTES
        Caches response to module variable 'Script:ResponseCache'
    #>

    [OutputType( 'GitServe.Route.Repo.List' )]
    param()

    GitServe.Repo.List
}

function /repo/log {
    <#
    .SYNOPSIS
        Return git logs based on repo OwnerRepoPair '/<owner>/<repo>'
    .DESCRIPTION
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
        since - "2.months"
        after - '2024-01-01'
        before - '2024-01-01'
 
        name: [string]
            The short 'OwnerRepoPair' for a cloned repo. Like:
            BurntSushi/ripgrep
 
        limit: [int]
            Return at most this many records.
            ( The git logs limit parameter )
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep'
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep&limit=4'
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep&before=2025-01-01&limit=2'
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep&since=2.weeks&limit=4'
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep&before=2.month&limit=3'
        irm 'http://127.0.0.1:3001/repo/log?name=BurntSushi/ripgrep&since=2.month&limit=3'
    #>


    [OutputType( 'GitServe.Route.Repo.Log' )]
    [Alias('GitServe.Route.Get-Log')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request
    )
    $endpointLabel = '/repo/log'
    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request

    #region Build Git Args
    [string] $OwnerRepoPair = $parsedQuery.Get('name')
    [int] $MaxLogs = $parsedQuery.Get('limit')

    if ( [String]::IsNullOrWhitespace( $ClonedRepoRoot ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'RootPath: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }
    $RepoPath = Join-Path $ClonedRepoRoot $OwnerRepoPair # todo(sanitization): use a better escape and match method
    if ( ! ( Test-Path $RepoPath )) {
        "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'" | Write-Host -fore red
        throw "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'"
    }
    # build git limiting args, which are common across ugit and git

    [Collections.Generic.List[object]] $gitArgs = @(
        'log'
        if ( $MaxLogs ) {
            '-n'
            $MaxLogs
        }
    )

    $SelectProperty = 'CommitDate', 'GitUserName', 'Date', 'Scope', 'CommitType', 'Merged', 'CommitHash', 'Trailer', 'Trailers'
    $UGit_splat = @{
        FromPath = $RepoPath
        GitArgList = $gitArgs
    }
    if( $parsedQuery.Get('since') ) {
        $UGit_splat['since'] = $parsedQuery.Get('since')
    }
    if( $parsedQuery.Get('before') ) {
        $UGit_splat['before'] = $parsedQuery.Get('before')
    }
    if( $parsedQuery.Get('after') ) {
        $UGit_splat['after'] = $parsedQuery.Get('after')
    }
    #endregion Build Git Args

    #region Invoke Git
    try {

        $results = Invoke-GitServeUGit @UGit_splat
            | Select-Object -Property $SelectProperty
    }
    catch {
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Host
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Error
    }
    return $results
    #endregion Invoke Git
}

function /repo/md/readme {
    <#
    .SYNOPSIS
        Get main readme file for a project
    .DESCRIPTION
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/md/readme?name=BurntSushi/ripgrep'
    .LINK
    #>

    [OutputType( 'GitServe.Route.Repo.Md.File' )]
    [Alias('GitServe.Route.Metric.Md.Readme')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request

    )
    $endpointLabel = '/repo/md/readme'
    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request
    [string] $OwnerRepoPair = $parsedQuery.Get('name')
    $RepoPath = Path.ConvertFrom-ShortRepoName -Name $OwnerRepoPair -Throw

    $found = Get-ChildItem -LiteralPath $RepoPath -Recurse -Filter readme.md -File
    $first = $found | Select-Object -First 1

    " ${endpointLabel} Found $( $found.count ) 'readme.md'" | Write-Verbose -Verbose

    if ( $found.count -gt 1 ) {
        " ${endpointLabel} Found $( $found.count ) 'readme.md'" | Write-Host
    }
    return [pscustomobject][ordered]@{
        PSTypeName = 'GitServe.Route.Repo.Md.File'
        Name       = $first.Name
        FullName   = $first.FullName.ToString()     # to prevent cache or json returning bytes
    }

}

function /repo/metric/totalcommit {
    <#
    .SYNOPSIS
        Number of commits grouped and sorted by: "<CommitDate> Descending
    .DESCRIPTION
        You get one single aggregated record for each **date period**
 
    Query Parameters:
        name - Short repo name like "BurntSushi/ripgrep"
        since - "2.months"
        after - '2024-01-01'
        before - '2024-01-01'
 
    example record:
        PSTypeName = 'GitServe.Route.Repo.Metric.TotalCommit'
 
        [string] XAxisKey : It is the same thing as the DateDisplayString
        [int] TotalCommits : Aggregate in this period across all authors
        [string] RepoName : OwnerRepo pairs
        [date] CommitDate : [datetime] of the first CommitDate record in the group
        [string[]] Authors : Aggregate list of all authors across the period
 
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep'
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&period=month'
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&period=day'
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&period=year'
    .EXAMPLE
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&since=2.months'
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&after=2024-01-01'
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=BurntSushi/ripgrep&before=2026-01-01'
    .example
        # multiple filters
        irm 'http://127.0.0.1:3001/repo/metric/totalcommit?name=startautomating/ezout&after=2024-01-01&before=2024-09-04'
    .EXAMPLE
    .LINK
        GitServe\Metric-GitServeCommitCount
    #>

    [OutputType( 'GitServe.Route.Repo.Metric.TotalCommit' )]
    [Alias('GitServe.Route.Metric.Commit')]
    [CmdletBinding()]
    param(
        # a request from the listen server
        [Parameter(Mandatory)]
        [object] $Request
    )


    $endpointLabel = '/repo/metric/commit'
    [Collections.Specialized.NameValueCollection] $parsedQuery = ParseQueryString $Request

    [string] $OwnerRepoPair = $parsedQuery.Get('name')
    [string] $Period = $parsedQuery.Get('period') ?? 'year'

    if ( [String]::IsNullOrWhitespace( $ClonedRepoRoot ) ) {
        $ClonedRepoRoot = GetConfig.ClonedRepoRoot | Get-Item -ea 'stop'
        'RootPath: {0}' -f ( $ClonedRepoRoot ) | Write-Verbose
    }

    #region Build Git Args
    $RepoPath = Join-Path $ClonedRepoRoot $OwnerRepoPair # todo(sanitization): use a better escape and match method
    if( ! ( Test-Path $RepoPath )) {
        "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'" | Write-Host -fore red
        throw "${endpointLabel} Error: Invalid OwnerRepoPair! '${OwnerRepoPair}'"
    }
    [Collections.Generic.List[object]] $gitArgs = @(
        'log'
    )

    $SelectProperty = 'CommitDate', 'GitUserName', 'Date', 'Scope', 'CommitType', 'Merged', 'CommitHash', 'Trailer', 'Trailers'
    $UGit_splat = @{
        FromPath = $RepoPath
        GitArgList = $gitArgs
    }
    if( $parsedQuery.Get('since') ) {
        $UGit_splat['since'] = $parsedQuery.Get('since')
    }
    if( $parsedQuery.Get('before') ) {
        $UGit_splat['before'] = $parsedQuery.Get('before')
    }
    if( $parsedQuery.Get('after') ) {
        $UGit_splat['after'] = $parsedQuery.Get('after')
    }

    #endregion Build Git Args
    #region Invoke Git Args
    try {

        [object[]] $results = Invoke-GitServeUGit @UGit_splat
            | Select-Object -Property $SelectProperty
            | GitServe.Metric.CommitCount -Period $Period
    }
    catch {
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Host
        "${endpointLabel} Error: Failed to get logs for '${OwnerRepoPair}' => $($_.Exception.Message)"
        | Write-Error
    }
    finally { }

    # todo(performance): redundant operations here

    # first determine date dimension keys. Insert into sorted hashtable in-order
    $results = $results | Sort-Object CommitDate
    $groupByPeriod =  $results | Group-Object -Prop { $_.CommitDate.ToString('yyyy-MM-dd') }
    [string[]] $datePeriodKeys = $groupByPeriod.Name

    # insert numerically sorted list of strings in the order
    $dateAccum = [ordered]@{}
    foreach( $name in $datePeriodKeys ) {
        $dateAccum[ $name ] = [ordered]@{}
    }

    # aggregate group into a single record per this date period
    foreach( $groupRecord in $groupByPeriod ) {
        $curKey = $groupRecord.Name
        $totalCommits = $groupRecord.Group
            | Measure-Object -Sum -Property 'CommitCount'
            | Select-Object -ExpandProperty Sum

        $firstDate = $groupRecord.Group
                | Measure-object CommitDate -Minimum
                | % Minimum

        $dateAccum[ $curKey ] = [pscustomobject]@{
            PSTypeName   = 'GitServe.Route.Repo.Metric.TotalCommit'
            XAxisKey     = $curKey                                               # typeof: [String] . It is the same thing as the DateDisplayString
            TotalCommits = [int]( $totalCommits ?? 0)                            # typeof: [int]
            RepoName     = $OwnerRepoPair
            Authors      = $groupRecord.Group.GitUserName | Sort-Object -Unique
            CommitDate   = $FirstDate                                            # typeof: [DateTime]
            # OwnerRepoName = $OwnerRepoPair
        }
    }
    # $trash | Group -p { $_.CommitDate.Date } | sort count
    # return ,$results
    return ,$dateAccum.Values
    #endregion Invoke Git Args
}


#endregion Public Functions


#region Module.After.ps1

# Use Module Removed Event for Cleanup
# This could be turned into a "common module filename" at '/Private/Module.OnRemoveModule.ps1'

if( $ModuleState.Using_CleanupOnRemoveEvent ) {
    $ExecutionContext.SessionState.Module.OnRemove = {
        OnRemoveModule_Handler
    }
}


#endregion Module.After.ps1