PSCodeDemo.psm1


function git {
    Write-Verbose "git $args"
    git.exe $args
    if ($LASTEXITCODE) {
        throw "git $args failed with exit code $LASTEXITCODE"
    }
}

class DemoCompleter : System.Management.Automation.IArgumentCompleter {
    [System.Collections.Generic.IEnumerable[System.Management.Automation.CompletionResult]] CompleteArgument(
        [System.String] $commandName,
        [System.String] $parameterName,
        [System.String] $wordToComplete,
        [System.Management.Automation.Language.CommandAst] $commandAst,
        [System.Collections.IDictionary] $fakeBoundParameters
    ) {
        switch ("${CommandName}:$ParameterName") {
            "Start-CodeDemo:FromCommit" {
                $repoPath = $fakeBoundParameters['RepositoryPath']
                return $this.CompleteCommit($repoPath, $wordToComplete, 1, 0)
            }
            
            "Start-CodeDemo:ToCommit" {
                $repoPath = $fakeBoundParameters['RepositoryPath']
                return $this.CompleteCommit($repoPath, $wordToComplete, 1, 0)
            }
        }
        return [System.Management.Automation.CompletionResult[]] @()
    }

    [System.Collections.Generic.IEnumerable[System.Management.Automation.CompletionResult]] CompleteCommit([string] $repositoryPath, [string] $wordToComplete, [int] $skipFirst, [int] $skipLast) {
        $entries = [GitLogEntry]::GetLogEntries($repositoryPath)
        [System.Management.Automation.CompletionResult[]]$completions = @(
            $entries | Select-Object -Skip:$skipFirst -SkipLast:$skipLast | Where-Object { $_.Commit.StartsWith($wordToComplete) } | ForEach-Object {
                [System.Management.Automation.CompletionResult]::new($_.Commit, $_.Title, 'ParameterValue', "{0}`n{1}`n{2}" -f $_.Title, $_.Author, $_.Commit )
            }
        )
        return $completions
    }
}


class GitLogEntry {
    [string] $Commit
    [string] $Title
    [string] $Author
    [string] $AuthorEmail
    [string] $Date
    [string] $FullMessage

    [string] ToString() {
        return "$($this.Commit.Substring(0, 7)) - `"$($this.Title)`""
    }

    static [GitLogEntry[]] GetLogEntries([string] $Path) {
        $entries = @(
            foreach ($line in git -C $path log --format='%h%x00%s%x00%f%x00%an%x00%ae%x00%aI') {
                $local:commit, $local:title, $local:fullMessage, $local:author, $local:authorEmail, $local:date = $line -split '\x00'
                [GitLogEntry] @{
                    Title       = $title
                    Commit      = $commit
                    Author      = $author
                    AuthorEmail = $authorEmail
                    Date        = $date
                    FullMessage = $fullMessage
                }
            }
        )
        return $entries
    }

    static [int] IndexOf([GitLogEntry[]] $Entries, [string] $Commit) {
        for ($i = 0; $i -lt $Entries.Count; $i++) {
            if ($Entries[$i].Commit -eq $Commit) {
                return $i
            }
        }
        return -1
    }
}

class GitTagEntry {
    [string] $Commit
    [string] $Name

    static [GitTagEntry[]] GetTagEntries([string] $Path) {
        $entries = @(
            foreach ($line in git -C $path tag --format='%(objectname)%00%(refname:lstrip=2)') {
                $local:commit, $local:name = $line -split '\0'
                [GitTagEntry] @{
                    Commit = $commit
                    Name   = $name
                }
            }
        )
        return $entries
    }
}

class PSCodeDemo {
    [string] $RepositoryPath
    [string] $WorkTree
    [GitLogEntry[]] $DemoCommits
    [int] $CurrentCommitIndex
    [string] $OriginalBranch

    PSCodeDemo([string] $RepositoryPath, [string] $WorkTree, [string] $FromCommit, [string] $ToCommit) {
        $this.RepositoryPath = $RepositoryPath
        $this.WorkTree = $WorkTree
        
        $tags = [GitTagEntry]::GetTagEntries($RepositoryPath)
        $log = [GitLogEntry]::GetLogEntries($RepositoryPath)
        $currentBranch = $this.GetCurrentBranch()
        if ($log.Count -lt 2) {
            Write-Error "The repository must have at least two commits to start a demo."
            return
        }
        if (!$FromCommit) {
            $fromTag = $tags | Where-Object { $_.Name -eq 'demo-start' }
            $FromCommit = ${fromTag}?.Commit ?? $log[-1].Commit
        }
        if (!$ToCommit) {
            $toTag = $tags | Where-Object { $_.Name -eq 'demo-end' }
            $ToCommit = ${toTag}?.Commit ?? $log[0].Commit
        }

        $fromIndex = [GitLogEntry]::IndexOf($log, $FromCommit)
        $toIndex = [GitLogEntry]::IndexOf($log, $ToCommit)

        $demoLog = $log[$fromIndex..$toIndex]
        $this.DemoCommits = $demoLog
        $this.CurrentCommitIndex = 0
        $this.OriginalBranch = $currentBranch    
    }


    [void] CreateDemoBranch() {
        git -C $this.RepositoryPath switch -C demo
    }

    [GitLogEntry] GetCurrentCommit() {
        return $this.DemoCommits[$this.CurrentCommitIndex]
    }

    [void] SwitchToDemoBranch() {
        git -C $this.RepositoryPath --work-tree=$($this.WorkTree) switch -C demo
    }

    [GitLogEntry] CheckoutCurrentCommit() {
        $currentObject = $this.GetCurrentCommit()
        $output = git --work-tree=$($this.WorkTree) -C $this.RepositoryPath checkout -f $currentObject.Commit
        return $currentObject
    }

    [GitLogEntry] Next() {
        if ($this.CurrentCommitIndex -lt ($this.DemoCommits.Count - 1)) {
            $this.CurrentCommitIndex++
            $this.CheckoutCurrentCommit()
        }
        return $this.GetCurrentCommit()
    }

    [GitLogEntry] Previous() {
        if ($this.CurrentCommitIndex -gt 0) {
            $this.CurrentCommitIndex--
            $this.CheckoutCurrentCommit()
        }

        return $this.GetCurrentCommit()
    }

    [string] ToString() {
        $current = $this.GetCurrentCommit()
        return "Demo on $($current.ToString())"
    }

    [string] GetCurrentBranch() {
        $output = git -C $this.RepositoryPath status --branch --porcelain
        switch -regex ($output) {
            '^\#\# HEAD \(no branch\)$' {
                throw 'Cannot start a demo when not on a branch.'
            }
            '^\#\# (.\S+)$' {
                return $Matches[1]
            }
            default {
                throw "Unexpected git status output: $output"
            }
        }
        return ""
    }
}

[PSCodeDemo] $script:DemoState


<#
.SYNOPSIS
    Start a code demo in a git repository
.DESCRIPTION
    Starts a demo in a git repository by remembering the current state of the repository and checking out the specified commit.
.PARAMETER Path
    The path to the git repository to start the demo in.
.PARAMETER FromCommit
    An optional commit to start the demo from. If not specified, the first commit in the history will be used, unless a tag 'demo-start' exists, in which case the commit the tag points to will be used.
.PARAMETER ToCommit
    An optional commit to end the demo at. If not specified, the current commit will be used, unless a tag 'demo-end' exists, in which case the commit the tag points to will be used.
.PARAMETER Force
    If a demo is already in progress, this switch will force a reset of the demo state and start a new demo.
#>

function Start-CodeDemo {
    [CmdletBinding()]
    param (
        [Parameter()]
        [string] $RepositoryPath,
        [string] $WorkTree = $PWD,
        [ArgumentCompleter([DemoCompleter])]
        [string] $FromCommit,
        [ArgumentCompleter([DemoCompleter])]
        [string] $ToCommit,
        [switch] $Force
    )

    $workTree = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($WorkTree)
    $RepositoryPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($RepositoryPath)
    if (-not (Test-Path -PathType:Container -LiteralPath:$workTree)) {
        Write-Error "The specified work tree path '$workTree' does not exist."
        return
    }
    if ($null -ne $script:DemoState) {
        if ($Force) {
            Write-Warning "A demo is already in progress. Forcing a new demo will reset the demo state."
            $script:DemoState = $null
        }
        else {
            Write-Error "A demo is already in progress. Use -Force to reset the demo state."
            return
        }
    }

    $demoState = [PSCodeDemo]::new($RepositoryPath, $WorkTree, $FromCommit, $ToCommit)

    $demoState.SwitchToDemoBranch()
    $demoState.CheckoutCurrentCommit()
    $script:DemoState = $demoState
}

function Update-CodeDemo {
    param(
        [switch] $PreviousCommit
    )

    $local:state = $script:DemoState
    if ($state) {
        if ($PreviousCommit) {
            $state.Previous()
        }
        else {
            $state.Next()
        }
    }
}

function Stop-CodeDemo {
    $state = $script:DemoState
    git -C $state.RepositoryPath switch -f $state.OriginalBranch
    $script:DemoState = $null
}

function Install-CodeDemoKeyHandler {
    Set-PSReadLineKeyHandler -Chord Ctrl-UpArrow { Update-CodeDemo }
    Set-PSReadLineKeyHandler -Chord Ctrl-DownArrow { Update-CodeDemo -PreviousCommit }
}

function Uninstall-CodeDemoKeyHandler {
    Remove-PSREadLineKeyHandler -Chord Ctrl-UpArrow
    Remove-PSREadLineKeyHandler -Chord Ctrl-DownArrow
}