VeilVer.psm1

#Region './Private/Get-GitBlobHash.ps1' -1

function Get-GitBlobHash {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path
    )

    $BlobHash = Invoke-GitCommand 'hash-object', '-t', 'blob', $Path

    Write-Output $BlobHash
}
#EndRegion './Private/Get-GitBlobHash.ps1' 13
#Region './Private/Get-GitBlobTag.ps1' -1

function Get-GitBlobTag {
    [CmdletBinding()]
    param (
        # Does not need to exist anymore, but must be a valid path
        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [ValidateScript({ Test-Path $_ -IsValid }, ErrorMessage = 'Must be a valid path format, but does not need to exist (anymore).')]
        [string]$RelativeRootPath,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All
    )

    # Example tag: VV/demo/docs/Contoso/Doc1.md/v1.0.0
    # Gets the pattern of tags with wildcard
    if ($All.IsPresent) {
        $TagPattern = Get-GitBlobTagName -All
    } else {
        $TagPattern = Get-GitBlobTagName -RelativeRootPath $RelativeRootPath -Pattern
    }

    # Get tags with version data split by semicolon, sorted by version in descending order
    $Tags = Invoke-GitCommand 'tag', '--list', '--format=%(refname:short);%(contents)', '--sort=-version:refname', $TagPattern |
        Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
        ForEach-Object {
            $Tag, $Base64Data = $_ -split ';'
            
            $TagVersionString = ($Tag -split '/')[-1].TrimStart('v')
            $TagVersion = [version]$TagVersionString

            # The tag command returns an empty line as part of the tag message, so we need to filter it out
            $JsonData = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Base64Data))
            $Metadata = $JsonData | ConvertFrom-Json

            # Get the hash of the blob that the tag points to / contains
            $Hash = (Invoke-GitCommand 'rev-list', '--objects', $Tag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch $Tag }).Trim()

            [pscustomobject]@{
                'Path' = [string]::IsNullOrWhiteSpace($RelativeRootPath) ? [Regex]::Match($Tag,'^@VV/(?<file>.*)/v[\d.]+$').Groups['file'].Value : $RelativeRootPath
                'Tag' = $Tag
                'Hash' = $Hash
                'Version' = $TagVersion
                'Metadata' = $Metadata
            }
        }

    if ($Tags.Count -eq 0) {
        return
    }

    Write-Verbose @"
Found the following hidden version tag(s) for the path '$RelativeRootPath':
- $(($Tags | ForEach-Object { $_.Version }) -join "`n- ")
"@


    Write-Output $Tags
}
#EndRegion './Private/Get-GitBlobTag.ps1' 57
#Region './Private/Get-GitBlobTagName.ps1' -1

function Get-GitBlobTagName {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [Parameter(Mandatory, ParameterSetName = 'Pattern')]
        [ValidateScript({ Test-Path $_ -IsValid }, ErrorMessage = 'Must be a valid path format, but does not need to exist (anymore).')]
        [string]$RelativeRootPath,

        [Parameter(Mandatory, ParameterSetName = 'Path')]
        [version]$Version,
        
        [Parameter(Mandatory, ParameterSetName = 'Pattern')]
        [switch]$Pattern,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All
    )

    if ($All.IsPresent) {
        $OutputString = "@VV/*"
    } elseif ($Pattern) {
        $OutputString = "@VV/$RelativeRootPath/v*"
    } else {
        $OutputString = "@VV/$RelativeRootPath/v$Version"
    }

    Write-Output $OutputString
}
#EndRegion './Private/Get-GitBlobTagName.ps1' 29
#Region './Private/Get-GitBranchDefaultRemote.ps1' -1

function Get-GitBranchDefaultRemote {
    [CmdletBinding()]
    param (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Branch = (Invoke-GitCommand 'branch', '--show-current')
    )

    # Get the default remote of the branch
    $DefaultRemote = Invoke-GitCommand 'branch', '--list', $Branch, '--format=%(upstream:remotename)'
    if ([string]::IsNullOrWhiteSpace($DefaultRemote)) {
        throw "Did not find a configured remote for the branch '$Branch'."
    }

    Write-Output $DefaultRemote
}
#EndRegion './Private/Get-GitBranchDefaultRemote.ps1' 17
#Region './Private/Get-GitCurrentCommit.ps1' -1

function Get-GitCurrentCommit {
    [CmdletBinding()]
    param ()

    Invoke-GitCommand 'rev-parse', '--verify', 'HEAD'
}
#EndRegion './Private/Get-GitCurrentCommit.ps1' 7
#Region './Private/Get-GitFileHistoryNames.ps1' -1

function Get-GitFileHistoryNames {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    # Get all names that the file has had by looking at commits that have changed the file
    # Automatically sorted by most recent (current) name first
    # Empty format string to only get the file names
    $FileNames = Invoke-GitCommand 'log', '--format=', '--name-only', '--follow', '--', $Path | Select-Object -Unique

    Write-Verbose @"
Found the following file path(s) of the file from the commit history:
- $($FileNames -join "`n- ")
"@


    Write-Output $FileNames
}
#EndRegion './Private/Get-GitFileHistoryNames.ps1' 20
#Region './Private/Get-GitRepoRoot.ps1' -1

function Get-GitRepoRoot {
    [CmdletBinding()]
    param()

    Invoke-GitCommand 'rev-parse', '--show-toplevel'
}
#EndRegion './Private/Get-GitRepoRoot.ps1' 7
#Region './Private/Get-RelativeRootFilePath.ps1' -1

function Get-RelativeRootFilePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -IsValid }, ErrorMessage = 'Must be a valid path format, but does not need to exist (anymore).')]
        [string]$Path
    )

    Push-Location (Get-GitRepoRoot)

    # Trim slashes from relative path
    $RelativePath = (Resolve-Path $Path -Relative).TrimStart('.\').TrimStart('./')

    Pop-Location

    Write-Output $RelativePath
}
#EndRegion './Private/Get-RelativeRootFilePath.ps1' 18
#Region './Private/Invoke-GitCommand.ps1' -1

function Invoke-GitCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Arguments
    )

    Test-GitInstallation -ErrorAction Stop

    Write-Verbose "Invoking 'git $Arguments'."

    & git $Arguments

    if ($LASTEXITCODE -ne 0) {
        throw "Command 'git $Arguments' failed with exit code $LASTEXITCODE."
    }
}
#EndRegion './Private/Invoke-GitCommand.ps1' 18
#Region './Private/Remove-GitBlobTag.ps1' -1

function Remove-GitBlobTag {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Tag
    )

    $null = Invoke-GitCommand 'tag', '--delete', $Tag
}
#EndRegion './Private/Remove-GitBlobTag.ps1' 10
#Region './Private/Test-GitBlobTag.ps1' -1

function Test-GitBlobTag {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Tag
    )
    
    try {
        $null = Invoke-GitCommand 'tag', '--list', $Tag
    } catch {}

    # Returns true if the tag exists
    $LASTEXITCODE -eq 0
}
#EndRegion './Private/Test-GitBlobTag.ps1' 15
#Region './Private/Test-GitFileIsModified.ps1' -1

function Test-GitFileIsModified {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path
    )

    try {
        $null = Invoke-GitCommand 'diff-index', 'HEAD', '--quiet', '--', $Path
    } catch {}
    
    # Returns true if the file has been modified
    $LASTEXITCODE -eq 1
}
#EndRegion './Private/Test-GitFileIsModified.ps1' 16
#Region './Private/Test-GitInstallation.ps1' -1

function Test-GitInstallation {
    [CmdletBinding()]
    param()

    if (-not (Get-Command "git" -ErrorAction SilentlyContinue)) {
        throw 'No installation of git was found, please install git to use this module.'
    }
}
#EndRegion './Private/Test-GitInstallation.ps1' 9
#Region './Public/Get-VVVersion.ps1' -1

function Get-VVVersion {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Path')]
        [Parameter(Mandatory, ParameterSetName = 'Checkout')]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path,
        
        [Parameter(ParameterSetName = 'Path')]
        [Parameter(Mandatory, ParameterSetName = 'Checkout')]
        [ValidateNotNullOrEmpty()]
        [version]$Version,

        [Parameter(Mandatory, ParameterSetName = 'Checkout')]
        [switch]$Checkout,

        [Parameter(ParameterSetName = 'Checkout')]
        [switch]$Force,

        [Parameter(ParameterSetName = 'All')]
        [switch]$All
    )

    if ($All.IsPresent) {
        $Tags = Get-GitBlobTag -All
        $Paths = $Tags | ForEach-Object { [Regex]::Match($_.Tag,'^@VV/(?<file>.*)/v[\d.]+$').Groups['file'].Value } | Select-Object -Unique
        $Paths | ForEach-Object { Get-VVVersion -Path $_ -ErrorAction SilentlyContinue } | Write-Output
        return
    }

    # Get all tags based on file names
    $FileNames = Get-GitFileHistoryNames -Path $Path
    
    $Tags = $FileNames | ForEach-Object {
        Get-GitBlobTag -RelativeRootPath $_
    }

    if ($Tags.Count -eq 0) {
        Write-Warning "No hidden version tags found for the file '$Path'."
        return
    }

    # If a version is specified, return the tag with the version
    if ($null -ne $Version -and -not $Checkout.IsPresent) {
        $TagInfo = $Tags | Where-Object Version -eq $Version
        if ($null -eq $TagInfo) {
            throw "The specified version '$Version' of file '$Path' does not exist."
        }
        return $TagInfo
    }

    # If checkout is specified, checkout the version
    if ($Checkout.IsPresent) {
        if ((Test-GitFileIsModified -Path $Path) -and -not $Force.IsPresent) {
            throw "The file '$Path' has been modified. Please commit or discard the changes before checking out a version, or override the file using the -Force parameter."
        }

        $TagInfo = $Tags | Where-Object Version -eq $Version
        
        if ($null -eq $TagInfo) {
            throw "The specified version '$Version' of file '$Path' does not exist."
        }

        $FileContent = Invoke-GitCommand 'show', $TagInfo.Hash
        Set-Content -Path $Path -Value $FileContent -Force:$Force.IsPresent
        Write-Verbose "Checked out version '$Version' of '$Path' successfully."
        return
    }

    # Return all tags if no version is specified
    Write-Output $Tags
}
#EndRegion './Public/Get-VVVersion.ps1' 73
#Region './Public/Import-VVVersion.ps1' -1

function Import-VVVersion {
    [Alias('Pull-VVVersion')]
    [CmdletBinding(DefaultParameterSetName = 'All')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Tag')]
        [ValidatePattern('^@VV', ErrorMessage = 'Tag must start with the prefix "@VV".')]
        [string]$Tag,

        [Parameter(ParameterSetName = 'Tag')]
        [Parameter(ParameterSetName = 'All')]
        [ValidateNotNullOrEmpty()]
        [string]$Remote = (Get-GitBranchDefaultRemote),

        [Parameter(ParameterSetName = 'Tag')]
        [Parameter(ParameterSetName = 'All')]
        [switch]$Force
    )

    # Get all VeilVer tags from the git remote
    $RemoteTags = Invoke-GitCommand 'ls-remote', '--tags', $Remote, 'refs/tags/@VV*' | Where-Object { -not $_.EndsWith('^{}') } | ForEach-Object { $_.Split("`t")[-1] }

    if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Tag') -and $RemoteTags -notcontains "refs/tags/$Tag") {
        throw "The tag '$Tag' does not exist on the remote '$Remote'."
    }

    # Get all VeilVer tags from the local git repository
    $LocalTags = Invoke-GitCommand 'tag', '--list', '@VV*'

    $NewRemoteTags = $RemoteTags | ForEach-Object { $_.TrimStart('refs/tags/') } | Where-Object { $_ -notin $LocalTags }

    switch ($PSCmdlet.ParameterSetName) {
        'All' {
            # Fetch all VeilVer tags from the git remote
            # Overwrite local different tags with the same name if -Force is specified
            $Pattern = $Force.IsPresent ? '+refs/tags/@VV*:refs/tags/@VV*' : 'refs/tags/@VV*:refs/tags/@VV*'
            $null = Invoke-GitCommand 'fetch', $Remote, $Pattern, '--quiet'

            if ($NewRemoteTags.Count -eq 0) {
                Write-Warning "No new hidden version tags found on the remote '$Remote'."
                return
            }

            Write-Verbose @"
Fetched the following hidden version tag(s):
- $($NewRemoteTags -join "`n- ")
"@

        }
        'Tag' {
            # Overwrite local tags with the same name if -Force is specified
            $Pattern = $Force.IsPresent ? "+refs/tags/${Tag}:refs/tags/${Tag}" : "refs/tags/${Tag}:refs/tags/${Tag}"

            $null = Invoke-GitCommand 'fetch', $Remote, $Pattern, '--quiet'

            Write-Verbose "Fetched the hidden version tag '$Tag' from the remote '$Remote'."
        }
    }
}
#EndRegion './Public/Import-VVVersion.ps1' 58
#Region './Public/Push-VVVersion.ps1' -1

function Push-VVVersion {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [ValidateNotNullOrEmpty()]
        [string]$Path,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [ValidateNotNullOrEmpty()]
        [version]$Version,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Tag')]
        [ValidatePattern('^@VV', ErrorMessage = 'Tag must start with the prefix "@VV".')]
        [string]$Tag,

        [Parameter(ParameterSetName = 'Tag')]
        [Parameter(ParameterSetName = 'Path')]
        [ValidateNotNullOrEmpty()]
        [string]$Remote = (Get-GitBranchDefaultRemote)
    )

    switch ($PSCmdlet.ParameterSetName) {
        'Path' {
            $Tag = (Get-VVVersion -Path $Path -Version $Version).Tag
        }
    }

    # Push the tag to the git remote
    $null = Invoke-GitCommand 'push', $Remote, 'tag', $Tag, '--quiet'

    Write-Verbose "Pushed tag '$Tag' to remote '$Remote'."
}
#EndRegion './Public/Push-VVVersion.ps1' 34
#Region './Public/Remove-VVVersion.ps1' -1

function Remove-VVVersion {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Path')]
        [version]$Version,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Tag')]
        [string]$Tag
    )

    # If parameter set name
    if ($PSCmdlet.ParameterSetName -eq 'Path') {
        # Get all tags based on file names
        $FileNames = Get-GitFileHistoryNames -Path $Path

        $Tags = $FileNames | ForEach-Object {
            Get-GitBlobTag -RelativeRootPath $_
        }

        $Tag = $Tags | Where-Object { $_.Version -eq $Version } | Select-Object -ExpandProperty Tag
        
        if ($null -eq $Tag) {
            Write-Warning "No hidden version tags found for the file '$Path' with version '$Version'."
            return
        }
    }

    try {
        Remove-GitBlobTag -Tag $Tag -ErrorAction Stop
    
        Write-Verbose "Successfully removed the hidden version tag '$Tag'."
    }
    catch {
        throw "Failed to remove the hidden version tag '$Tag'."
    }
}
#EndRegion './Public/Remove-VVVersion.ps1' 41
#Region './Public/Set-VVVersion.ps1' -1

function Set-VVVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path,

        [Parameter(Mandatory)]
        [version]$Version,

        [Parameter(Mandatory)]
        [hashtable]$Metadata
    )

    # Get the relative path of the file from the root of the repo and trim start
    $RelativePath = Get-RelativeRootFilePath -Path $Path
    $TagName = Get-GitBlobTagName -RelativeRootPath $RelativePath -Version $Version

    # Ensure that the file has no pending changes, since we are tagging the file content together with the commit and don't want any discrepancies
    if (Test-GitFileIsModified -Path $RelativePath) {
        Write-Warning "The file '$RelativePath' has been modified. Please commit the changes before setting the version."
        return
    }

    # Set extra metadata for the tag
    if ($Metadata.ContainsKey('Commit')) { Write-Warning "The 'Commit' key was provided, and will be used instead of current commit." }
    $Metadata['Commit'] ??= Get-GitCurrentCommit
    
    # Assemble metadata, convert to JSON and then to Base64
    $JsonMetadata = $Metadata | ConvertTo-Json -Compress -Depth 20
    $Base64Metadata = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($JsonMetadata))

    # Tag the file with the version data as json in the tag message
    $BlobHash = Get-GitBlobHash -Path $Path
    Invoke-GitCommand 'tag', '-a', $TagName, $BlobHash, '-m', $Base64Metadata -ErrorAction Stop

    Write-Verbose "Hidden tag '$TagName' has been created for '$RelativePath'."
}
#EndRegion './Public/Set-VVVersion.ps1' 39
#Region './Public/Sync-VVVersion.ps1' -1

function Sync-VVVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [ValidateScript({ Test-Path $_ -PathType Leaf }, ErrorMessage = 'Path must exist and be a file.')]
        [string]$Path
    )

    # Get the relative path of the file from the root of the repo and trim start
    $RelativePath = Get-RelativeRootFilePath -Path $Path

    # Ensure that the file has no pending changes, since we are tagging the file content together with the commit and don't want any discrepancies
    if (Test-GitFileIsModified -Path $RelativePath) {
        Write-Warning "The file '$RelativePath' has been modified. Please commit the changes before setting the version."
        return
    }

    # Ensure the file exists
    if (-not (Test-Path -Path $Path)) {
        Write-Error "File path '$Path' does not exist."
        return
    }

    # Retrieve all historical names of the file
    $HistoricalNames = Get-GitFileHistoryNames -Path $Path

    # Find all tags associated with the file's historical names
    $Tags = $HistoricalNames | ForEach-Object {
        Get-GitBlobTag -RelativeRootPath $_
    }

    # Remove old tags and recreate them with the new file path
    foreach ($Tag in $Tags) {
        if ($Tag.File -ne $Path) {
            Remove-VVVersion -Tag $Tag.Tag
            Set-VVVersion -Path $Path -Version $Tag.Version -Metadata (
                $Tag.Metadata.psobject.properties |
                    ForEach-Object -Begin { $Metahash = @{} } -Process { $Metahash[$_.Name] = $_.Value } -End { $Metahash }    
            )
        }
    }

    Write-Verbose "All git tags for '$Path' have been updated to its current file name."
}
#EndRegion './Public/Sync-VVVersion.ps1' 45