completers/Git_completer/Git_completer.ps1

# Git Native Argument Completer for PowerShell
# Provides intelligent tab completion for git commands, subcommands, and flags.
# This implementation uses git's own capabilities to fetch relevant completions,
# ensuring that it stays up-to-date with the installed version of git.
   
function Complete-GitNative {
    param($wordToComplete, $commandAst, $cursorPosition)

    if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
        return
    }

    $line = $commandAst.ToString()

    # $cursorPosition indexes the whole input line while $line is command-relative, and only the
    # tokens that start before the cursor may be considered: otherwise mid-line completion treats
    # the word under the cursor as an already-chosen subcommand.
    $relativeCursor = $cursorPosition - $commandAst.Extent.StartOffset
    $isPastCommandEnd = $relativeCursor -gt $line.Length
    $boundedCursor = [Math]::Min([Math]::Max($relativeCursor, 0), $line.Length)

    $tokens = @(
        [regex]::Matches($line, '\S+') |
            Where-Object { $_.Index -lt $boundedCursor } |
            ForEach-Object { $_.Value }
    )

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

    $hasTrailingSpace = $isPastCommandEnd -or ($line.Substring(0, $boundedCursor) -match '\s$')
    if ($hasTrailingSpace) {
        $argIndex = $tokens.Count - 1
    }
    else {
        $argIndex = $tokens.Count - 2
    }

    if ($argIndex -lt 0) {
        $argIndex = 0
    }

    $newResult = {
        param($value)
        [System.Management.Automation.CompletionResult]::new($value, $value, 'ParameterValue', $value)
    }

    # git's short flags are case-distinct (-b/-B, -d/-D, -n/-N), so a typed single-dash word is
    # matched case-sensitively; everything else keeps the case-insensitive prefix match.
    $matchesWord = {
        param([string]$value)
        $pattern = [System.Management.Automation.WildcardPattern]::Escape($wordToComplete) + '*'
        if ($wordToComplete -cmatch '^-[^-]') { $value -clike $pattern } else { $value -like $pattern }
    }

    $completeList = {
        param([string[]]$values)
        $values |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
            Sort-Object -Unique -CaseSensitive |
            Where-Object { & $matchesWord $_ } |
            ForEach-Object { & $newResult $_ }
    }

    $completeOrderedList = {
        param([string]$prefix, [string[]]$values)

        $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($value in $values) {
            if ([string]::IsNullOrWhiteSpace($value)) {
                continue
            }

            if (-not $seen.Add($value)) {
                continue
            }

            if ($value -like ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')) {
                & $newResult $value
            }
        }
    }

    $completeFileSystemPaths = {
        param(
            [string]$pathPrefix,
            [string]$completionPrefix = '',
            [switch]$DirectoriesOnly
        )

        [System.Management.Automation.CompletionCompleters]::CompleteFilename($pathPrefix) |
            ForEach-Object {
                # CompleteFilename quotes a path that needs it; the quotes have to come off before
                # the option prefix is attached, or they land in the middle of the token and git
                # receives a literal quote inside the path.
                $rawPath = $_.CompletionText.Trim([char[]]@([char]39, [char]34))

                if ($DirectoriesOnly -and -not (Test-Path -LiteralPath $rawPath -PathType Container)) {
                    return
                }

                if ([string]::IsNullOrEmpty($completionPrefix)) {
                    $_
                    return
                }

                $completionText = "$completionPrefix$rawPath"
                if ($completionText -match '\s') {
                    $completionText = "'" + $completionText.Replace("'", "''") + "'"
                }

                [System.Management.Automation.CompletionResult]::new(
                    $completionText,
                    $completionText,
                    'ParameterValue',
                    $completionText
                )
            }
    }

    # The global '-C <path>', '--git-dir' and '--work-tree' options point git at another
    # repository; $getCommandContext records them here so every repository read follows them.
    $gitRepoArgs = [System.Collections.Generic.List[string]]::new()

    $getRefs = {
        @(
            git @gitRepoArgs for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>$null
            git @gitRepoArgs rev-parse --short HEAD 2>$null
        ) | Where-Object { $_ }
    }

    $getLocalBranches = {
        @(git @gitRepoArgs for-each-ref --format='%(refname:short)' refs/heads 2>$null) | Where-Object { $_ }
    }

    $getTags = {
        @(git @gitRepoArgs for-each-ref --format='%(refname:short)' refs/tags 2>$null) | Where-Object { $_ }
    }

    # Branch names on one remote, without the '<remote>/' prefix, as fetch/pull refspecs name them.
    $getRemoteBranches = {
        param([string]$remoteName)

        $prefix = "$remoteName/"
        @(git @gitRepoArgs for-each-ref --format='%(refname:short)' "refs/remotes/$remoteName" 2>$null) |
            Where-Object { $_ -and $_.StartsWith($prefix) } |
            ForEach-Object { $_.Substring($prefix.Length) } |
            Where-Object { $_ -ne 'HEAD' }
    }

    $getStashEntries = {
        @(git @gitRepoArgs stash list --format='%gd' 2>$null) | Where-Object { $_ }
    }

    $getRemotes = {
        @(git @gitRepoArgs remote 2>$null)
    }

    $getFiles = {
        @(
            git @gitRepoArgs ls-files 2>$null
            git @gitRepoArgs ls-files --others --exclude-standard 2>$null
        ) | Where-Object { $_ }
    }

    $getWorktreePaths = {
        @(
            git @gitRepoArgs worktree list --porcelain 2>$null |
                Where-Object { $_ -like 'worktree *' } |
                ForEach-Object { $_.Substring(9) }
        ) | Where-Object { $_ }
    }

    # Fallback for the root option list; the live list is parsed from the usage block of
    # 'git --help' by $getGlobalFlags below.
    $globalGitFlags = @(
        '-v',
        '--version',
        '-h',
        '--help',
        '-C',
        '-c',
        '--exec-path',
        '--html-path',
        '--man-path',
        '--info-path',
        '-p',
        '--paginate',
        '-P',
        '--no-pager',
        '--no-replace-objects',
        '--no-lazy-fetch',
        '--no-optional-locks',
        '--no-advice',
        '--bare',
        '--git-dir',
        '--work-tree',
        '--namespace',
        '--config-env'
    )

    $globalGitFlagsWithValues = @(
        '-C',
        '-c',
        '--git-dir',
        '--work-tree',
        '--namespace',
        '--exec-path',
        '--config-env'
    )

    $globalGitDirectoryFlags = @('-C', '--git-dir', '--work-tree')

    if (-not (Get-Variable -Name GitHelpMetadataCache -Scope Global -ErrorAction Ignore)) {
        $global:GitHelpMetadataCache = @{}
    }

    # One local handle to the session cache; the hashtable is shared by reference.
    $metadataCache = $global:GitHelpMetadataCache

    $getGitAliases = {
        if ($metadataCache.ContainsKey('<aliases>')) {
            return $metadataCache['<aliases>']
        }

        $aliases = @{}
        foreach ($configLine in @(git config --get-regexp '^alias\.' 2>$null)) {
            if ($configLine -match '^alias\.(?<name>\S+)\s+(?<body>.*)$') {
                $aliases[$matches['name']] = $matches['body']
            }
        }

        $metadataCache['<aliases>'] = $aliases
        $aliases
    }

    # A non-shell alias whose body names a real git command completes as that command. Shell
    # aliases ('!cmd') and alias chains resolve to nothing.
    $resolveGitAliasTarget = {
        param([string]$name)

        $aliasTable = & $getGitAliases
        if (-not $aliasTable.ContainsKey($name)) {
            return $null
        }

        $aliasBody = [string]$aliasTable[$name]
        if ($aliasBody.StartsWith('!')) {
            return $null
        }

        $aliasTarget = @($aliasBody -split '\s+' | Where-Object { $_ }) | Select-Object -First 1
        if ($aliasTarget -and
            $aliasTarget -match '^[A-Za-z0-9][A-Za-z0-9-]*$' -and
            -not $aliasTable.ContainsKey($aliasTarget)) {
            return $aliasTarget
        }

        $null
    }

    # The root usage block of 'git --help' lists every global option in bracket groups
    # ('[-v | --version] [-C <path>] [--no-advice] ...'), so the list follows the installed git.
    $getGlobalFlags = {
        if ($metadataCache.ContainsKey('<global-flags>')) {
            return $metadataCache['<global-flags>']
        }

        $flags = [System.Collections.Generic.List[string]]::new()
        $inUsage = $false
        foreach ($helpLine in @($null | git --help 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' })) {
            if ($helpLine -match '^usage:\s+git\b') {
                $inUsage = $true
            }
            elseif ($inUsage -and [string]::IsNullOrWhiteSpace($helpLine)) {
                break
            }

            if (-not $inUsage) {
                continue
            }

            foreach ($match in [regex]::Matches($helpLine, '(?<=[\[\s|])(-[A-Za-z]|--[A-Za-z][A-Za-z0-9-]*)(?=[\s\]|=])')) {
                $flags.Add($match.Value)
            }
        }

        $result = if ($flags.Count -gt 0) { @($flags | Select-Object -Unique) } else { @($globalGitFlags) }
        $metadataCache['<global-flags>'] = $result
        $result
    }

    $getCompletionScriptData = {
        if ($metadataCache.ContainsKey('<completion-script>')) {
            return $metadataCache['<completion-script>']
        }

        $data = [pscustomobject]@{
            Variables = @{}
            Lines     = @()
        }
        $metadataCache['<completion-script>'] = $data

        $gitCommand = Get-Command git -ErrorAction SilentlyContinue | Select-Object -First 1
        if (-not $gitCommand -or [string]::IsNullOrWhiteSpace($gitCommand.Source)) {
            return $data
        }

        # git.exe can resolve from <root>\cmd, <root>\bin or <root>\mingw64\bin, so no fixed number
        # of Split-Path hops finds the install root. Walk upward from the resolved directory and
        # from 'git --exec-path' (<root>\mingw64\libexec\git-core), probing the completion script
        # at each level; the first hit wins.
        $startDirectories = [System.Collections.Generic.List[string]]::new()
        $startDirectories.Add((Split-Path -Path $gitCommand.Source -Parent))
        $execPath = [string](@($null | git --exec-path 2>$null) | Select-Object -First 1)
        if (-not [string]::IsNullOrWhiteSpace($execPath)) {
            $startDirectories.Add($execPath.Replace('/', '\'))
        }

        $relativeCandidates = @(
            'share\git\completion\git-completion.bash',
            'mingw64\share\git\completion\git-completion.bash',
            'mingw32\share\git\completion\git-completion.bash',
            'usr\share\git\completion\git-completion.bash'
        )

        $scriptPath = $null
        foreach ($startDirectory in $startDirectories) {
            $directory = $startDirectory
            for ($level = 0; $level -lt 5 -and -not $scriptPath -and -not [string]::IsNullOrWhiteSpace($directory); $level++) {
                foreach ($relativePath in $relativeCandidates) {
                    $candidate = Join-Path -Path $directory -ChildPath $relativePath
                    if (Test-Path -LiteralPath $candidate -PathType Leaf) {
                        $scriptPath = $candidate
                        break
                    }
                }

                $directory = Split-Path -Path $directory -Parent
            }

            if ($scriptPath) {
                break
            }
        }

        if (-not $scriptPath) {
            return $data
        }

        $scriptLines = @(
            try {
                Get-Content -LiteralPath $scriptPath -ErrorAction Stop
            }
            catch {
                @()
            }
        )

        # The bundled bash completion keeps its option lists in '__git_<name>="..."' assignments
        # that may span several lines.
        $variables = @{}
        $pendingName = $null
        $pendingValue = $null
        foreach ($scriptLine in $scriptLines) {
            if ($null -eq $pendingName) {
                if ($scriptLine -match '^__git_([A-Za-z0-9_]+)="(.*)$') {
                    $pendingName = $matches[1]
                    $remainder = $matches[2]
                    if ($remainder -match '^(.*)"\s*$') {
                        $variables[$pendingName] = $matches[1]
                        $pendingName = $null
                    }
                    else {
                        $pendingValue = $remainder
                    }
                }

                continue
            }

            if ($scriptLine -match '^(.*)"\s*$') {
                $variables[$pendingName] = "$pendingValue $($matches[1])"
                $pendingName = $null
                $pendingValue = $null
            }
            else {
                $pendingValue = "$pendingValue $scriptLine"
            }
        }

        $data.Variables = $variables
        $data.Lines = $scriptLines
        $data
    }

    $expandCompletionScriptWord = {
        param([string]$word, [hashtable]$variables, [System.Collections.Generic.HashSet[string]]$visited, [System.Collections.Generic.List[string]]$sink)

        if ($word -match '^\$__git_([A-Za-z0-9_]+)$') {
            $variableName = $matches[1]
            if (-not $visited.Add($variableName) -or -not $variables.ContainsKey($variableName)) {
                return
            }

            foreach ($rawInner in ($variables[$variableName] -split '\s+')) {
                & $expandCompletionScriptWord $rawInner.Trim([char[]]@([char]34, [char]39)) $variables $visited $sink
            }

            return
        }

        if ($word -match '^--[A-Za-z0-9][A-Za-z0-9-]*=?$') {
            [void]$sink.Add(($word -replace '=$', ''))
        }
    }

    $getCompletionScriptOptions = {
        param([string]$functionName)

        $data = & $getCompletionScriptData
        if (@($data.Lines).Count -eq 0) {
            return @()
        }

        $start = -1
        for ($i = 0; $i -lt $data.Lines.Count; $i++) {
            if ($data.Lines[$i] -match ('^' + [regex]::Escape($functionName) + '\s*\(\)\s*$')) {
                $start = $i
                break
            }
        }

        if ($start -lt 0) {
            return @()
        }

        $collected = [System.Collections.Generic.List[string]]::new()
        for ($i = $start + 1; $i -lt $data.Lines.Count; $i++) {
            if ($data.Lines[$i] -match '^\}') {
                break
            }

            foreach ($rawWord in ($data.Lines[$i] -split '\s+')) {
                $visited = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
                & $expandCompletionScriptWord $rawWord.Trim([char[]]@([char]34, [char]39)) $data.Variables $visited $collected
            }
        }

        @($collected | Sort-Object -Unique -CaseSensitive)
    }

    # git deliberately prints an abbreviated '-h' for the revision-walking commands, so their real
    # option surface is read from the bash completion script that ships with git.
    $completionScriptFunctions = @{
        'log'         = '__git_complete_log_opts'
        'whatchanged' = '__git_complete_log_opts'
        'show'        = '_git_show'
        'diff'        = '_git_diff'
    }

    # The same script holds the value lists for the options those commands document only there.
    $completionScriptValueVariables = @{
        '--pretty'             = 'log_pretty_formats'
        '--format'             = 'log_pretty_formats'
        '--date'               = 'log_date_formats'
        '--diff-algorithm'     = 'diff_algorithms'
        '--submodule'          = 'diff_submodule_formats'
        '--ws-error-highlight' = 'ws_error_highlight_opts'
        '--color-moved'        = 'color_moved_opts'
        '--color-moved-ws'     = 'color_moved_ws_opts'
        '--diff-merges'        = 'diff_merges_opts'
    }

    $getCompletionScriptValues = {
        param([string]$variableName)

        $data = & $getCompletionScriptData
        if (-not $data.Variables.ContainsKey($variableName)) {
            return @()
        }

        @($data.Variables[$variableName] -split '\s+' | Where-Object { $_ })
    }

    $documentedNestedSubcommands = @{
        'hook'            = @('run')
        'maintenance'     = @('is-needed', 'register', 'run', 'start', 'stop', 'unregister')
        'notes'           = @('add', 'append', 'copy', 'edit', 'get-ref', 'list', 'merge', 'prune', 'remove', 'show')
        'reflog'          = @('delete', 'drop', 'exists', 'expire', 'list', 'show', 'write')
        'remote'          = @('add', 'get-url', 'prune', 'remove', 'rename', 'rm', 'set-branches', 'set-head', 'set-url', 'show', 'update')
        'sparse-checkout' = @('add', 'check-rules', 'clean', 'disable', 'init', 'list', 'reapply', 'set')
        'stash'           = @('apply', 'branch', 'clear', 'create', 'drop', 'export', 'import', 'list', 'pop', 'push', 'save', 'show', 'store')
        'submodule'       = @('absorbgitdirs', 'add', 'deinit', 'foreach', 'init', 'set-branch', 'set-url', 'status', 'summary', 'sync', 'update')
        'worktree'        = @('add', 'list', 'lock', 'move', 'prune', 'remove', 'repair', 'unlock')
    }

    $getTopLevelSubcommands = {
        if ($metadataCache.ContainsKey('<root>')) {
            return $metadataCache['<root>'].Subcommands
        }

        $subcommands = @(
            git --list-cmds=main,others,alias,nohelpers 2>$null
        )

        if (-not $subcommands -or $subcommands.Count -eq 0) {
            $subcommands = @(
                'add', 'bisect', 'branch', 'checkout', 'cherry-pick', 'clean', 'clone', 'commit', 'diff',
                'fetch', 'grep', 'hook', 'init', 'log', 'merge', 'mv', 'pull', 'push', 'rebase', 'reset', 'restore',
                'revert', 'rm', 'show', 'stash', 'status', 'switch', 'tag', 'worktree'
            )
        }

        $metadata = [pscustomobject]@{
            Flags              = @($globalGitFlags)
            Subcommands        = @(
                $subcommands |
                    Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
                    Sort-Object -Unique
            )
            OptionSpecs        = @{}
            OptionDescriptions = @{}
        }

        $metadataCache['<root>'] = $metadata
        $metadata.Subcommands
    }

    $getCommandMetadata = {
        param([string[]]$commandPath)

        $cacheKey = if ($commandPath.Count -gt 0) {
            $commandPath -join ' '
        }
        else {
            '<root>'
        }

        if ($cacheKey -eq '<root>' -and -not $metadataCache.ContainsKey($cacheKey)) {
            $null = & $getTopLevelSubcommands
        }

        if ($metadataCache.ContainsKey($cacheKey)) {
            return $metadataCache[$cacheKey]
        }

        # Never run '-h' against a user alias: git has no help to print for a shell alias
        # ('!cmd ...') and runs the alias body instead, so completion would execute it.
        if ($commandPath.Count -ge 1) {
            $aliasTable = & $getGitAliases
            if ($aliasTable.ContainsKey($commandPath[0])) {
                $aliasMetadata = $null
                $aliasTarget = & $resolveGitAliasTarget $commandPath[0]
                if ($aliasTarget) {
                    $aliasMetadata = & $getCommandMetadata (@($aliasTarget) + @($commandPath | Select-Object -Skip 1))
                }

                if (-not $aliasMetadata) {
                    $aliasMetadata = [pscustomobject]@{
                        Flags              = @()
                        Subcommands        = @()
                        OptionSpecs        = @{}
                        OptionDescriptions = @{}
                    }
                }

                $metadataCache[$cacheKey] = $aliasMetadata
                return $aliasMetadata
            }
        }

        $helpLines = @(
            & git @commandPath -h 2>&1 |
                ForEach-Object { $_.ToString() -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }
        )

        $optionPattern = '(?<!\w)(--\[(?:no-)\][A-Za-z0-9][A-Za-z0-9-]*|--[A-Za-z0-9][A-Za-z0-9-]*|-[A-Za-z])'
        $flags = [System.Collections.Generic.List[string]]::new()
        $optionSpecs = @{}
        $optionDescriptions = @{}
        $describedOptions = @()
        foreach ($line in $helpLines) {
            foreach ($match in [regex]::Matches($line, $optionPattern)) {
                $option = $match.Value
                if ($option -match '^--\[no-\](.+)$') {
                    $flags.Add("--$($Matches[1])")
                    $flags.Add("--no-$($Matches[1])")
                }
                else {
                    $flags.Add($option)
                }
            }

            # The option column of an option row carries the argument the option takes, for
            # example '-F, --[no-]file <file>' or '--[no-]conflict <style>'. Everything after the
            # last option token in that column is that argument's spec, and the description
            # column (same line, or the indented continuation lines) may spell out the values
            # a bare placeholder stands for ('optional modes: all, normal, no').
            if ($line -notmatch '^\s{4}\S') {
                if ($describedOptions.Count -gt 0 -and $line -match '^\s{6,}\S') {
                    foreach ($describedOption in $describedOptions) {
                        $optionDescriptions[$describedOption] = ($optionDescriptions[$describedOption] + ' ' + $line.Trim()).Trim()
                    }
                }
                else {
                    $describedOptions = @()
                }

                continue
            }

            $columns = @($line.Trim() -split '\s{2,}', 2)
            $optionColumn = $columns[0]
            $description = if ($columns.Count -gt 1) { $columns[1].Trim() } else { '' }
            $columnMatches = @([regex]::Matches($optionColumn, $optionPattern))
            if ($columnMatches.Count -eq 0) {
                $describedOptions = @()
                continue
            }

            $describedOptions = @(
                foreach ($columnMatch in $columnMatches) {
                    $specOption = $columnMatch.Value
                    if ($specOption -match '^--\[no-\](.+)$') {
                        $specOption = "--$($Matches[1])"
                    }

                    $optionDescriptions[$specOption] = $description
                    $specOption
                }
            )

            $lastMatch = $columnMatches[$columnMatches.Count - 1]
            $spec = $optionColumn.Substring($lastMatch.Index + $lastMatch.Length).Trim()
            if ([string]::IsNullOrWhiteSpace($spec)) {
                continue
            }

            # A long option column can run into its description with a single space
            # ('--[no-]cleanup <mode> how to strip spaces ...'); keep only the argument spec.
            $specSplit = [regex]::Match($spec, '^(?<spec>\[?=?(?:<[^>]+>|\([^)]*\))\]?)\s+(?<rest>\S.*)$')
            if ($specSplit.Success) {
                $spec = $specSplit.Groups['spec'].Value
                if ([string]::IsNullOrWhiteSpace($description)) {
                    foreach ($describedOption in $describedOptions) {
                        $optionDescriptions[$describedOption] = $specSplit.Groups['rest'].Value.Trim()
                    }
                }
            }

            foreach ($specOption in $describedOptions) {
                $optionSpecs[$specOption] = $spec
            }
        }

        if ($completionScriptFunctions.ContainsKey($cacheKey)) {
            foreach ($supplementFlag in @(& $getCompletionScriptOptions $completionScriptFunctions[$cacheKey])) {
                $flags.Add($supplementFlag)
            }
        }

        $subcommands = [System.Collections.Generic.List[string]]::new()
        foreach ($line in $helpLines) {
            if ($line -notmatch '^\s*(?:usage:|or:)\s+git\s+') {
                continue
            }

            $usage = $line -replace '^\s*(?:usage:|or:)\s+git\s+', ''
            $usageTokens = @(
                [regex]::Matches($usage, '\S+') |
                    ForEach-Object { $_.Value }
            )

            if ($usageTokens.Count -le $commandPath.Count) {
                continue
            }

            $matchesPath = $true
            for ($i = 0; $i -lt $commandPath.Count; $i++) {
                if ($usageTokens[$i] -ne $commandPath[$i]) {
                    $matchesPath = $false
                    break
                }
            }

            if (-not $matchesPath) {
                continue
            }

            $usageRemainder = ($usageTokens[$commandPath.Count..($usageTokens.Count - 1)] -join ' ')

            $alternativesMatch = [regex]::Match(
                $usageRemainder,
                '^\s*[\[(]*(?<commands>[A-Za-z0-9][A-Za-z0-9-]*(?:\s*\|\s*[A-Za-z0-9][A-Za-z0-9-]*)+)'
            )
            if ($alternativesMatch.Success) {
                foreach ($candidate in ($alternativesMatch.Groups['commands'].Value -split '\|')) {
                    $cleanCandidate = $candidate.Trim()
                    if ($cleanCandidate -match '^[A-Za-z0-9][A-Za-z0-9-]*$') {
                        $subcommands.Add($cleanCandidate)
                    }
                }
                continue
            }

            $singleCommandMatch = [regex]::Match(
                $usageRemainder,
                '^\s*[\[(]*(?<command>[A-Za-z0-9][A-Za-z0-9-]*)'
            )
            if ($singleCommandMatch.Success) {
                $subcommands.Add($singleCommandMatch.Groups['command'].Value)
            }
        }

        if ($documentedNestedSubcommands.ContainsKey($cacheKey)) {
            foreach ($documentedSubcommand in $documentedNestedSubcommands[$cacheKey]) {
                $subcommands.Add($documentedSubcommand)
            }
        }

        $metadata = [pscustomobject]@{
            Flags              = @($flags | Sort-Object -Unique -CaseSensitive)
            Subcommands        = @($subcommands | Sort-Object -Unique -CaseSensitive)
            OptionSpecs        = $optionSpecs
            OptionDescriptions = $optionDescriptions
        }

        $metadataCache[$cacheKey] = $metadata
        $metadata
    }

    # Values git documents only in prose, or not at all, for placeholder specs such as <mode>,
    # <style>, <strategy> and <option>. Keyed by '<command> <option>'.
    $curatedOptionValues = @{
        'commit --cleanup'          = @('strip', 'whitespace', 'verbatim', 'scissors', 'default')
        'commit -u'                 = @('all', 'normal', 'no')
        'commit --untracked-files'  = @('all', 'normal', 'no')
        'status -u'                 = @('all', 'normal', 'no')
        'status --untracked-files'  = @('all', 'normal', 'no')
        'checkout --conflict'       = @('merge', 'diff3', 'zdiff3')
        'switch --conflict'         = @('merge', 'diff3', 'zdiff3')
        'merge -s'                  = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'merge --strategy'          = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'rebase -s'                 = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'rebase --strategy'         = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'pull -s'                   = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'pull --strategy'           = @('ort', 'recursive', 'resolve', 'octopus', 'ours', 'subtree')
        'merge -X'                  = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
        'merge --strategy-option'   = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
        'rebase -X'                 = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
        'rebase --strategy-option'  = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
        'pull -X'                   = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
        'pull --strategy-option'    = @('ours', 'theirs', 'patience', 'diff-algorithm=', 'ignore-space-change', 'ignore-all-space', 'ignore-space-at-eol', 'ignore-cr-at-eol', 'renormalize', 'no-renormalize', 'find-renames=', 'subtree=')
    }

    # A value list spelled out in the description column: 'optional modes: all, normal, no.' or
    # 'conflict style (merge, diff3, or zdiff3)'.
    $getDescribedOptionValues = {
        param([string]$description)

        if ([string]::IsNullOrWhiteSpace($description)) {
            return @()
        }

        $listMatch = [regex]::Match(
            $description,
            '(?:modes?|values?|styles?|one of):\s*(?<values>[A-Za-z0-9_-]+(?:,\s*(?:or\s+)?[A-Za-z0-9_-]+)+)'
        )
        if (-not $listMatch.Success) {
            $listMatch = [regex]::Match(
                $description,
                '\((?<values>[A-Za-z0-9_-]+(?:,\s*(?:or\s+)?[A-Za-z0-9_-]+)+)\)'
            )
        }

        if (-not $listMatch.Success) {
            return @()
        }

        @($listMatch.Groups['values'].Value -split ',' | ForEach-Object { ($_ -replace '^\s*or\s+', '').Trim() } | Where-Object { $_ })
    }

    $getCommandContext = {
        param([string[]]$argsBeforeCursor)

        $commandPath = @()
        $metadata = & $getCommandMetadata $commandPath
        $lastNonFlagArgument = $null
        $pendingGlobalValueOption = $null

        foreach ($argument in $argsBeforeCursor) {
            if ($argument -eq '--') {
                break
            }

            # A global option such as '-C <path>' consumes the token after it, which is otherwise
            # mistaken for the subcommand. The repository options are kept, in order, for the
            # repository reads ('-C a -C b' and a relative '--git-dir' build on each other).
            if ($pendingGlobalValueOption) {
                if ($globalGitDirectoryFlags -ccontains $pendingGlobalValueOption) {
                    $gitRepoArgs.Add($pendingGlobalValueOption)
                    $gitRepoArgs.Add($argument.Trim([char[]]@([char]39, [char]34)))
                }

                $pendingGlobalValueOption = $null
                continue
            }

            if ($argument.StartsWith('-')) {
                if ($commandPath.Count -eq 0 -and $globalGitFlagsWithValues -contains $argument) {
                    $pendingGlobalValueOption = $argument
                }
                elseif ($commandPath.Count -eq 0 -and $argument -match '^(?<option>--git-dir|--work-tree)=(?<value>.+)$') {
                    $gitRepoArgs.Add("$($matches['option'])=$($matches['value'].Trim([char[]]@([char]39, [char]34)))")
                }

                continue
            }

            $lastNonFlagArgument = $argument
            if ($metadata.Subcommands -contains $argument) {
                $commandPath += $argument
                $metadata = & $getCommandMetadata $commandPath
            }
        }

        [pscustomobject]@{
            CommandPath              = @($commandPath)
            Metadata                 = $metadata
            LastNonFlagArgument      = $lastNonFlagArgument
            PendingGlobalValueOption = $pendingGlobalValueOption
        }
    }

    $getArgumentsAfterPath = {
        param([string[]]$argsBeforeCursor, [string[]]$commandPath)

        $remainingPath = [System.Collections.Generic.Queue[string]]::new()
        foreach ($segment in $commandPath) {
            $remainingPath.Enqueue($segment)
        }

        $arguments = [System.Collections.Generic.List[string]]::new()
        foreach ($argument in $argsBeforeCursor) {
            if ($remainingPath.Count -gt 0 -and $argument -eq $remainingPath.Peek()) {
                $null = $remainingPath.Dequeue()
                continue
            }

            if ($remainingPath.Count -gt 0) {
                continue
            }

            $arguments.Add($argument)
        }

        @($arguments)
    }

    $getPositionalArgumentsAfterPath = {
        param([string[]]$argsBeforeCursor, [string[]]$commandPath)

        @(
            & $getArgumentsAfterPath $argsBeforeCursor $commandPath |
                Where-Object { -not $_.StartsWith('-') }
        )
    }

    $analyzeArguments = {
        param(
            [string[]]$arguments,
            [string[]]$optionsWithValues = @()
        )

        $valueOptions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($optionName in $optionsWithValues) {
            if (-not [string]::IsNullOrWhiteSpace($optionName)) {
                $null = $valueOptions.Add($optionName)
            }
        }

        $positionals = [System.Collections.Generic.List[string]]::new()
        $pendingValueOption = $null
        $afterDoubleDash = $false

        foreach ($argument in $arguments) {
            if ($afterDoubleDash) {
                $positionals.Add($argument)
                continue
            }

            if ($argument -eq '--') {
                $afterDoubleDash = $true
                continue
            }

            if ($pendingValueOption) {
                $pendingValueOption = $null
                continue
            }

            if ($argument.StartsWith('-')) {
                $optionName = $argument
                $hasAttachedValue = $false

                $attachedLongOptionMatch = [regex]::Match(
                    $argument,
                    '^(?<option>--[A-Za-z0-9][A-Za-z0-9-]*)='
                )
                if ($attachedLongOptionMatch.Success) {
                    $optionName = $attachedLongOptionMatch.Groups['option'].Value
                    $hasAttachedValue = $true
                }

                if ($valueOptions.Contains($optionName) -and -not $hasAttachedValue) {
                    $pendingValueOption = $optionName
                }

                continue
            }

            $positionals.Add($argument)
        }

        [pscustomobject]@{
            Positionals        = @($positionals)
            PendingValueOption = $pendingValueOption
            AfterDoubleDash    = $afterDoubleDash
        }
    }

    $fallbackConfigKeys = @(
        'user.name',
        'user.email',
        'core.editor',
        'core.autocrlf',
        'core.safecrlf',
        'core.filemode',
        'core.ignorecase',
        'init.defaultBranch',
        'pull.rebase',
        'pull.ff',
        'push.default',
        'push.autoSetupRemote',
        'fetch.prune',
        'merge.ff',
        'merge.conflictStyle',
        'rebase.autoStash',
        'rebase.autoSquash',
        'rerere.enabled',
        'credential.helper',
        'credential.useHttpPath'
    )

    # 'git help --config-for-completion' lists every variable the installed git documents, with
    # bare section prefixes ('alias.', 'branch.', 'remote.') for the subsectioned ones. The
    # literal is the fallback when that list is unavailable; real alias names are added from
    # the alias table. The keys are narrowed to the typed prefix here, because the list is long
    # enough that the result pipelines would otherwise dominate a warm Tab.
    $getConfigKeys = {
        param([string]$prefix)

        if (-not $metadataCache.ContainsKey('<config-keys>')) {
            $configKeys = @(
                $null | git help --config-for-completion 2>$null |
                    ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' } |
                    Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
            )

            if ($configKeys.Count -eq 0) {
                $configKeys = @($fallbackConfigKeys)
            }

            $metadataCache['<config-keys>'] = $configKeys
        }

        @(@($metadataCache['<config-keys>']) + @((& $getGitAliases).Keys | ForEach-Object { "alias.$_" })) -like
            ([System.Management.Automation.WildcardPattern]::Escape($prefix) + '*')
    }

    $getCurrentBranch = {
        git @gitRepoArgs symbolic-ref --short HEAD 2>$null
    }

    $getHookNames = {
        $defaultHookNames = @(
            'applypatch-msg',
            'pre-applypatch',
            'post-applypatch',
            'pre-commit',
            'pre-merge-commit',
            'prepare-commit-msg',
            'commit-msg',
            'post-commit',
            'pre-rebase',
            'post-checkout',
            'post-merge',
            'pre-push',
            'pre-receive',
            'update',
            'proc-receive',
            'post-receive',
            'post-update',
            'reference-transaction',
            'push-to-checkout',
            'pre-auto-gc',
            'post-rewrite',
            'sendemail-validate',
            'fsmonitor-watchman'
        )

        $repoHookNames = @()
        # An absolute path, because a relative one is relative to the '-C' directory, not ours.
        $hooksPath = git @gitRepoArgs rev-parse --path-format=absolute --git-path hooks 2>$null
        if (-not [string]::IsNullOrWhiteSpace($hooksPath)) {
            $repoHookNames = @(
                Get-ChildItem -Path $hooksPath -File -ErrorAction SilentlyContinue |
                    Where-Object { $_.Name -notlike '*.sample' } |
                    ForEach-Object { $_.BaseName }
            )
        }

        @($defaultHookNames + $repoHookNames) |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
            Sort-Object -Unique
    }

    $getNewBranchSuggestions = {
        $currentBranch = & $getCurrentBranch
        $names = @(
            'feature/',
            'bugfix/',
            'hotfix/',
            'chore/',
            'docs/',
            'refactor/',
            'test/'
        )

        if (-not [string]::IsNullOrWhiteSpace($currentBranch)) {
            $names += @(
                "$currentBranch-fix",
                "$currentBranch-update"
            )
        }

        $names
    }

    $getInitBranchSuggestions = {
        $configuredBranch = git config --get init.defaultBranch 2>$null
        @(
            $configuredBranch
            'main'
            'master'
            'develop'
            'trunk'
        ) |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
            Sort-Object -Unique
    }

    $getInitFlagCompletions = {
        param([string[]]$metadataFlags)

        $preferredFlags = @(
            '--quiet',
            '-q',
            '--bare',
            '--template=',
            '--separate-git-dir',
            '--object-format=',
            '--ref-format=',
            '-b',
            '--initial-branch=',
            '--shared',
            '--shared='
        )

        $remainingFlags = @(
            $metadataFlags |
                Where-Object { $_ -notin @('--template', '--object-format', '--ref-format', '--initial-branch') }
        )

        @($preferredFlags + $remainingFlags)
    }

    $getHelpGuides = {
        @(git --list-cmds=list-guide 2>$null) | Where-Object { $_ }
    }

    $completeOptionSpecValue = {
        param(
            [string]$spec,
            [string]$valuePrefix,
            [string]$completionPrefix = '',
            [string]$optionName = ''
        )

        if ([string]::IsNullOrWhiteSpace($spec)) {
            return
        }

        $normalized = $spec.Trim().Trim([char[]]@('[', ']'))
        if ($normalized.StartsWith('=')) {
            $normalized = $normalized.Substring(1)
        }

        $normalized = $normalized.Trim([char[]]@('[', ']'))

        $emit = {
            param([string[]]$values)

            if ([string]::IsNullOrEmpty($completionPrefix)) {
                & $completeOrderedList $valuePrefix $values
            }
            else {
                & $completeOrderedList $wordToComplete @($values | ForEach-Object { "$completionPrefix$_" })
            }
        }

        $enumMatch = [regex]::Match(
            $normalized,
            '^\(?(?<values>[A-Za-z0-9][A-Za-z0-9._-]*(?:\|[A-Za-z0-9][A-Za-z0-9._-]*)+)\)?$'
        )
        if ($enumMatch.Success) {
            & $emit @($enumMatch.Groups['values'].Value -split '\|')
            return
        }

        # Curated values and the description column outrank the placeholder word, which is
        # what turns '<mode>', '<style>' and '<option>' into real choices.
        if (-not [string]::IsNullOrWhiteSpace($optionName)) {
            $curatedKey = "$commandText $optionName"
            if ($curatedOptionValues.ContainsKey($curatedKey)) {
                & $emit @($curatedOptionValues[$curatedKey])
                return
            }

            if ($optionDescriptions.ContainsKey($optionName)) {
                $describedValues = @(& $getDescribedOptionValues $optionDescriptions[$optionName])
                if ($describedValues.Count -gt 0) {
                    & $emit $describedValues
                    return
                }
            }
        }

        switch -Regex ($normalized.Trim([char[]]@('<', '>')).ToLowerInvariant()) {
            '^(file|path|dir|directory|template-directory|gitdir|pathspec)$' {
                & $completeFileSystemPaths $valuePrefix $completionPrefix
                return
            }
            '^new-branch$' {
                & $emit @(& $getNewBranchSuggestions)
                return
            }
            '^(commit|commit-ish|committish|object|tree-ish|treeish|rev|revision|ref|reference|branch|start-point|upstream)$' {
                & $emit @(& $getRefs)
                return
            }
        }
    }

    $completeInitOptionValues = {
        param(
            [string]$optionName,
            [string]$valuePrefix,
            [switch]$Attached
        )

        switch ($optionName) {
            '--template' {
                $completionPrefix = if ($Attached) { '--template=' } else { '' }
                & $completeFileSystemPaths $valuePrefix $completionPrefix -DirectoriesOnly
                return
            }
            '--separate-git-dir' {
                $completionPrefix = if ($Attached) { '--separate-git-dir=' } else { '' }
                & $completeFileSystemPaths $valuePrefix $completionPrefix -DirectoriesOnly
                return
            }
            '--object-format' {
                $values = @('sha1', 'sha256')
                if ($Attached) {
                    & $completeOrderedList $wordToComplete ($values | ForEach-Object { "--object-format=$_" })
                }
                else {
                    & $completeOrderedList $valuePrefix $values
                }
                return
            }
            '--ref-format' {
                $values = @('files', 'reftable')
                if ($Attached) {
                    & $completeOrderedList $wordToComplete ($values | ForEach-Object { "--ref-format=$_" })
                }
                else {
                    & $completeOrderedList $valuePrefix $values
                }
                return
            }
            { $_ -in @('-b', '--initial-branch') } {
                $values = @(& $getInitBranchSuggestions)
                if ($Attached) {
                    & $completeOrderedList $wordToComplete ($values | ForEach-Object { "--initial-branch=$_" })
                }
                else {
                    & $completeOrderedList $valuePrefix $values
                }
                return
            }
            '--shared' {
                $values = @('false', 'true', 'umask', 'group', 'all', 'world', 'everybody', '0640', '0660', '0770')
                if ($Attached) {
                    & $completeOrderedList $wordToComplete ($values | ForEach-Object { "--shared=$_" })
                }
                else {
                    & $completeOrderedList $valuePrefix $values
                }
                return
            }
        }
    }

    $preferredSubcommands = @(
        'status',
        'add',
        'commit',
        'push',
        'pull',
        'fetch',
        'hook',
        'switch',
        'checkout',
        'branch',
        'merge',
        'rebase',
        'log',
        'diff',
        'stash',
        'tag',
        'restore',
        'reset',
        'rm'
    )

    if ($argIndex -le 0) {
        if ($wordToComplete -like '-*') {
            & $completeList (& $getGlobalFlags)
            return
        }

        $allSubcommands = @(& $getTopLevelSubcommands)

        $orderedSubcommands = @()
        foreach ($name in $preferredSubcommands) {
            if ($allSubcommands -contains $name) {
                $orderedSubcommands += $name
            }
        }

        $orderedSubcommands += @(
            $allSubcommands |
                Where-Object { $preferredSubcommands -notcontains $_ } |
                Sort-Object
        )

        $orderedSubcommands |
            Where-Object { $_ -like ([System.Management.Automation.WildcardPattern]::Escape($wordToComplete) + '*') } |
            ForEach-Object { & $newResult $_ }
        return
    }

    $argsBeforeCursor = @()
    if ($argIndex -ge 1) {
        $argsBeforeCursor = @($tokens[1..$argIndex])
    }

    $commandContext = & $getCommandContext $argsBeforeCursor
    # The typed path strips the user's tokens; the effective path, with a non-shell alias
    # replaced by its target, drives every command-specific value slot below.
    $typedCommandPath = @($commandContext.CommandPath)
    $commandPath = $typedCommandPath
    if ($commandPath.Count -gt 0) {
        $aliasTarget = & $resolveGitAliasTarget $commandPath[0]
        if ($aliasTarget) {
            $commandPath = @($aliasTarget) + @($commandPath | Select-Object -Skip 1)
        }
    }
    $commandText = $commandPath -join ' '
    $subcommand = if ($commandPath.Count -gt 0) { $commandPath[0] } else { $null }
    $argsAfterPath = @(& $getArgumentsAfterPath $argsBeforeCursor $typedCommandPath)
    $positionalsAfterPath = @(& $getPositionalArgumentsAfterPath $argsBeforeCursor $typedCommandPath)

    if ($commandContext.PendingGlobalValueOption) {
        if ($globalGitDirectoryFlags -contains $commandContext.PendingGlobalValueOption) {
            & $completeFileSystemPaths $wordToComplete '' -DirectoriesOnly
        }

        return
    }

    $optionSpecs = if ($commandContext.Metadata.OptionSpecs) { $commandContext.Metadata.OptionSpecs } else { @{} }
    $optionDescriptions = if ($commandContext.Metadata.OptionDescriptions) { $commandContext.Metadata.OptionDescriptions } else { @{} }

    # 'checkout -b/-B' and 'switch -c/-C' name a branch to be created; their '<branch>' spec
    # must not resolve to the existing refs the generic spec handler offers.
    $branchCreationOptions = switch ($subcommand) {
        'checkout' { @('-b', '-B', '--orphan') }
        'switch' { @('-c', '-C', '--orphan') }
        default { @() }
    }

    if ($commandText -eq 'init') {
        $attachedInitValueMatch = [regex]::Match(
            $wordToComplete,
            '^(?<option>--template|--separate-git-dir|--object-format|--ref-format|--initial-branch|--shared)=(?<value>.*)$'
        )
        if ($attachedInitValueMatch.Success) {
            & $completeInitOptionValues $attachedInitValueMatch.Groups['option'].Value $attachedInitValueMatch.Groups['value'].Value -Attached
            return
        }

        if ($argsAfterPath.Count -gt 0) {
            $previousInitArgument = $argsAfterPath[-1]
            if ($previousInitArgument -in @('--template', '--separate-git-dir', '--object-format', '--ref-format', '-b', '--initial-branch', '--shared')) {
                & $completeInitOptionValues $previousInitArgument $wordToComplete
                return
            }
        }
    }

    if ($subcommand -eq 'config') {
        $attachedConfigFileMatch = [regex]::Match(
            $wordToComplete,
            '^(?<option>--file)=(?<value>.*)$'
        )
        if ($attachedConfigFileMatch.Success) {
            & $completeFileSystemPaths $attachedConfigFileMatch.Groups['value'].Value '--file='
            return
        }
    }

    $attachedOptionValueMatch = [regex]::Match(
        $wordToComplete,
        '^(?<option>--[A-Za-z0-9][A-Za-z0-9-]*)=(?<value>.*)$'
    )
    if ($attachedOptionValueMatch.Success) {
        $attachedOption = $attachedOptionValueMatch.Groups['option'].Value
        $attachedValue = $attachedOptionValueMatch.Groups['value'].Value

        if ($completionScriptFunctions.ContainsKey($commandText) -and
            $completionScriptValueVariables.ContainsKey($attachedOption)) {
            $scriptValues = @(& $getCompletionScriptValues $completionScriptValueVariables[$attachedOption])
            if ($scriptValues.Count -gt 0) {
                & $completeOrderedList $wordToComplete @($scriptValues | ForEach-Object { "$attachedOption=$_" })
                return
            }
        }

        if ($attachedOption -in $branchCreationOptions) {
            & $completeOrderedList $wordToComplete @(& $getNewBranchSuggestions | ForEach-Object { "$attachedOption=$_" })
            return
        }

        if ($optionSpecs.ContainsKey($attachedOption)) {
            & $completeOptionSpecValue $optionSpecs[$attachedOption] $attachedValue "$attachedOption=" $attachedOption
            return
        }
    }

    if ($wordToComplete -like '-*') {
        if ($commandText -eq 'init') {
            & $completeOrderedList $wordToComplete (& $getInitFlagCompletions $commandContext.Metadata.Flags)
        }
        else {
            & $completeList $commandContext.Metadata.Flags
        }
        return
    }

    if ($argsAfterPath.Count -gt 0) {
        $previousOption = $argsAfterPath[-1]

        if ($previousOption.StartsWith('-') -and
            $completionScriptFunctions.ContainsKey($commandText) -and
            $completionScriptValueVariables.ContainsKey($previousOption)) {
            $scriptValues = @(& $getCompletionScriptValues $completionScriptValueVariables[$previousOption])
            if ($scriptValues.Count -gt 0) {
                & $completeOrderedList $wordToComplete $scriptValues
                return
            }
        }

        if ($previousOption -in $branchCreationOptions) {
            & $completeList (& $getNewBranchSuggestions)
            return
        }

        if ($previousOption.StartsWith('-') -and $optionSpecs.ContainsKey($previousOption)) {
            & $completeOptionSpecValue $optionSpecs[$previousOption] $wordToComplete '' $previousOption
            return
        }
    }

    # After '--' every remaining argument is a pathspec, whatever the command: tracked and
    # untracked files for the index-editing commands, the filesystem for everything else.
    if ($argsAfterPath -contains '--') {
        if ($subcommand -in @('add', 'restore', 'rm', 'mv')) {
            & $completeList (& $getFiles)
        }
        else {
            & $completeFileSystemPaths $wordToComplete
        }

        return
    }

    if ($commandText -eq 'help' -and $positionalsAfterPath.Count -eq 0) {
        & $completeOrderedList $wordToComplete @(@(& $getTopLevelSubcommands) + @(& $getHelpGuides))
        return
    }

    if ($subcommand -eq 'config') {
        $configAnalysis = & $analyzeArguments $argsAfterPath @('-f', '--file', '--blob', '--type', '--default', '--comment')

        if ($configAnalysis.PendingValueOption -in @('-f', '--file')) {
            & $completeFileSystemPaths $wordToComplete
            return
        }

        switch ($commandText) {
            'config' {
                if ($configAnalysis.Positionals.Count -eq 0) {
                    & $completeOrderedList $wordToComplete @($commandContext.Metadata.Subcommands + (& $getConfigKeys $wordToComplete))
                    return
                }
            }
            { $_ -in @('config get', 'config set', 'config unset') } {
                if ($configAnalysis.Positionals.Count -eq 0) {
                    & $completeList (& $getConfigKeys $wordToComplete)
                    return
                }
            }
        }
    }

    if ($commandContext.Metadata.Subcommands.Count -gt 0) {
        $isAtSubcommandBoundary = [string]::IsNullOrWhiteSpace($commandContext.LastNonFlagArgument)
        if (-not $isAtSubcommandBoundary -and $commandPath.Count -gt 0) {
            $isAtSubcommandBoundary = $commandContext.LastNonFlagArgument -eq $typedCommandPath[-1]
        }

        if ($commandPath.Count -eq 0) {
            $isAtSubcommandBoundary = $true
        }

        if ($isAtSubcommandBoundary) {
            & $completeList $commandContext.Metadata.Subcommands
            return
        }
    }

    $shouldCompleteLeafFlags = (
        $hasTrailingSpace -and
        [string]::IsNullOrEmpty($wordToComplete) -and
        $commandPath.Count -gt 0 -and
        $positionalsAfterPath.Count -eq 0 -and
        $commandContext.Metadata.Subcommands.Count -eq 0
    )

    if ($commandText -eq 'hook run') {
        if ($argsAfterPath.Count -gt 0 -and $argsAfterPath[-1] -eq '--to-stdin') {
            return
        }

        $hookName = $null
        for ($i = 0; $i -lt $argsAfterPath.Count; $i++) {
            $token = $argsAfterPath[$i]

            if ($token -eq '--') {
                break
            }

            if ($token -eq '--to-stdin') {
                $i++
                continue
            }

            if ($token -like '--to-stdin=*' -or $token.StartsWith('-')) {
                continue
            }

            $hookName = $token
            break
        }

        if (-not $hookName) {
            & $completeList (& $getHookNames)
        }

        return
    }

    switch ($commandText) {
        'worktree add' {
            if ($positionalsAfterPath.Count -ge 1) {
                & $completeList (& $getRefs)
            }
            return
        }
        { $_ -in @('worktree lock', 'worktree move', 'worktree remove', 'worktree repair', 'worktree unlock') } {
            & $completeList (& $getWorktreePaths)
            return
        }
        'worktree list' {
            return
        }
        'worktree prune' {
            return
        }
        { $_ -in @('remote remove', 'remote rm') } {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            return
        }
        'remote rename' {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            return
        }
        'remote set-head' {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            elseif ($positionalsAfterPath.Count -lt 2) {
                & $completeList (& $getRefs)
            }
            return
        }
        { $_ -in @('remote show', 'remote prune', 'remote update', 'remote get-url') } {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            return
        }
        'remote set-branches' {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            else {
                & $completeList (& $getRefs)
            }
            return
        }
        'remote set-url' {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
            }
            return
        }
        { $_ -in @('stash apply', 'stash pop', 'stash drop', 'stash show') } {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getStashEntries)
            }
            return
        }
        'stash branch' {
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getNewBranchSuggestions)
            }
            elseif ($positionalsAfterPath.Count -lt 2) {
                & $completeList (& $getStashEntries)
            }
            return
        }
    }

    switch ($subcommand) {
        { $_ -in @('checkout', 'switch') } {
            $branchCreationOptions = if ($subcommand -eq 'checkout') { @('-b', '-B') } else { @('-c', '-C') }
            $branchCreationAnalysis = & $analyzeArguments $argsAfterPath $branchCreationOptions
            if ($branchCreationAnalysis.PendingValueOption -in $branchCreationOptions) {
                & $completeList (& $getNewBranchSuggestions)
                return
            }

            & $completeList (& $getRefs)
            return
        }
        { $_ -in @('merge', 'rebase', 'reset', 'show', 'log', 'diff', 'cherry-pick', 'revert') } {
            & $completeList (& $getRefs)
            return
        }
        { $_ -in @('push', 'pull', 'fetch') } {
            # 'git push [<repository> [<refspec>...]]': the first slot is a remote, later slots
            # are refspecs (local branches and tags to push; the remote's branches to fetch/pull).
            if ($positionalsAfterPath.Count -lt 1) {
                & $completeList (& $getRemotes)
                return
            }

            if ($subcommand -eq 'push') {
                & $completeList (@(& $getLocalBranches) + @(& $getTags))
                return
            }

            $remoteBranches = @(& $getRemoteBranches $positionalsAfterPath[0])
            if ($remoteBranches.Count -eq 0) {
                $remoteBranches = @(& $getLocalBranches)
            }

            & $completeList $remoteBranches
            return
        }
        { $_ -in @('add', 'restore', 'rm', 'mv') } {
            & $completeList (& $getFiles)
            return
        }
        'branch' {
            & $completeList (& $getRefs)
            return
        }
        'tag' {
            $tagNames = @(& $getTags)
            if ($tagNames.Count -gt 0) {
                & $completeList $tagNames
            }
            elseif ($shouldCompleteLeafFlags) {
                & $completeList $commandContext.Metadata.Flags
            }
            return
        }
        default {
            if ($commandText -eq 'init' -and $shouldCompleteLeafFlags) {
                & $completeOrderedList '' (& $getInitFlagCompletions $commandContext.Metadata.Flags)
                & $completeFileSystemPaths '' -DirectoriesOnly
                return
            }

            if ($shouldCompleteLeafFlags) {
                & $completeList $commandContext.Metadata.Flags
                return
            }

            return
        }
    }
}

Register-ArgumentCompleter -Native -CommandName @('git', 'git.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-GitNative -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}