Get-GitTag.ps1

function Get-GitTag {
    <#
    .SYNOPSIS
        Get typed information about local git tags.
    .DESCRIPTION
        Reads refs/tags with git for-each-ref, sorted by reference name. Does not
        fetch, change the repository, or change the caller's location. Supports
        working trees (including subdirectories and linked worktrees) and bare
        repositories. No tags or no matching names produces no output.
    .PARAMETER Name
        One or more exact tag names or PowerShell wildcard patterns, matched
        case-sensitively against the full name without the refs/tags/ prefix.
        Defaults to all tags. Overlapping patterns do not duplicate results.
    .PARAMETER Path
        Literal directory inside the repository. Defaults to the current location.
        Accepts pipeline paths and objects with Path, RepositoryPath or RepoPath.
    .OUTPUTS
        GitTag
        Name and Reference identify the tag. ObjectId and ObjectType describe
        the referenced object (type 'tag' for annotated tags). IsAnnotated is a
        Boolean. TargetObjectId and TargetObjectType describe the fully peeled
        target; TargetCommit is its full ID only when that target is a commit,
        otherwise null. Tags pointing to blobs, trees and other tags are supported.
 
        Subject is git's contents:subject (the tag subject for annotated tags,
        commit subject for lightweight commit tags, or empty where unavailable).
        Annotation is git's contents field for annotated tags, including whitespace
        and any signature, or null for lightweight tags. It is UTF-8 decoded,
        not a byte-for-byte representation of non-UTF-8 objects. TaggerDate and
        CreatorDate are DateTimeOffset values, or null when unavailable. For
        lightweight commit tags CreatorDate is the commit's committer date, not
        a tag creation date (which git does not record). RepositoryPath is the
        resolved input directory, not necessarily the repository root.
    .EXAMPLE
        Get-GitTag
        Lists all local tags in the current repository.
    .EXAMPLE
        Get-GitTag -Name 'v1.*', 'stable' -Path ../project
        Lists matching local tags without changing location.
    .EXAMPLE
        Get-Worktrees | Get-GitTag -Name 'v*'
        Lists matching tags for each supplied worktree.
    #>

    [OutputType('GitTag')]
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [Alias('TagName')]
        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string[]]$Name = @('*'),

        [Parameter(Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('RepositoryPath', 'RepoPath')]
        [string]$Path
    )

    begin {
        $fields = @(
            'refname', 'objecttype', 'objectname', '*objecttype', '*objectname',
            'taggerdate:iso-strict', 'creatordate:iso-strict', 'contents:subject', 'contents'
        )
        $format = ($fields | ForEach-Object { "%($_)" }) -join '%00'
        # Git shell-quotes each atom, escaping apostrophes and exclamation marks.
        # Parse the quotes as data, never execute them. Unlike splitting on NUL
        # or newlines, this framing also handles delimiters within tag messages.
        $quotedField = "'((?:[^']|'\\['!]')*)'"
        $recordPattern = [regex]::new('\G' + (($fields | ForEach-Object { $quotedField }) -join '\x00') + '\r?\n')
        $gitEnvironment = @{ GIT_NO_LAZY_FETCH = '1' }
    }

    process {
        $result = Invoke-Git -Path $Path -AllowBare -Environment $gitEnvironment -Arguments @(
            'for-each-ref', '--shell', '--sort=refname', "--format=$format", '--', 'refs/tags/'
        )
        if ($null -eq $result) { return }

        $offset = 0
        while ($offset -lt $result.StandardOutput.Length) {
            $match = $recordPattern.Match($result.StandardOutput, $offset)
            if (-not $match.Success) {
                $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                    [System.FormatException]::new('Unexpected git for-each-ref tag output.'),
                    'GitTagFormatInvalid', [System.Management.Automation.ErrorCategory]::InvalidData, $result.RepositoryPath))
            }
            $offset += $match.Length
            $values = @($match.Groups | Select-Object -Skip 1 | ForEach-Object {
                $_.Value.Replace("'\''", "'").Replace("'\!'", '!')
            })
            $tagName = $values[0].Substring('refs/tags/'.Length)
            $matchesName = $false
            foreach ($pattern in $Name) {
                if ($tagName -clike $pattern) { $matchesName = $true; break }
            }
            if (-not $matchesName) { continue }

            $isAnnotated = $values[1] -ceq 'tag'
            $targetType = if ($isAnnotated) { $values[3] } else { $values[1] }
            $targetId = if ($isAnnotated) { $values[4] } else { $values[2] }
            if ($targetType -ceq 'tag') {
                # Peel any remaining tag levels using the captured object ID,
                # not a ref that may have moved since for-each-ref.
                $peeled = Invoke-Git -Path $result.RepositoryPath -AllowBare -Environment $gitEnvironment -Arguments @(
                    'rev-parse', '--verify', '--end-of-options', "$($values[2])^{}"
                )
                if ($null -eq $peeled) { continue }
                $targetId = $peeled.StandardOutput.Trim()
                $type = Invoke-Git -Path $result.RepositoryPath -AllowBare -Environment $gitEnvironment -Arguments @('cat-file', '-t', $targetId)
                if ($null -eq $type) { continue }
                $targetType = $type.StandardOutput.Trim()
            }

            $dates = foreach ($value in $values[5..6]) {
                if ($value) {
                    [DateTimeOffset]::Parse($value, [System.Globalization.CultureInfo]::InvariantCulture)
                } else {
                    $null
                }
            }
            [PSCustomObject]@{
                PSTypeName       = 'GitTag'
                Name             = $tagName
                Reference        = $values[0]
                ObjectId         = $values[2]
                ObjectType       = $values[1]
                IsAnnotated      = $isAnnotated
                TargetObjectId   = $targetId
                TargetObjectType = $targetType
                TargetCommit     = if ($targetType -ceq 'commit') { $targetId } else { $null }
                Subject          = $values[7]
                Annotation       = if ($isAnnotated) { $values[8] } else { $null }
                TaggerDate       = $dates[0]
                CreatorDate      = $dates[1]
                RepositoryPath   = $result.RepositoryPath
            }
        }
    }
}