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 |