Public/GitTabCompletion.ps1

# Git tab completion — self-contained replacement for posh-git tab expansion.
# Registers a native argument completer for git that provides context-aware
# completions for subcommands, branches, tags, remotes, stashes, files, and
# parameters using only the git CLI (no external module dependency).

# Subcommand sub-operations
$script:gitSubcommands = @{
    bisect    = 'start bad good skip reset visualize replay log run'
    notes     = 'add append copy edit get-ref list merge prune remove show'
    reflog    = 'show delete expire'
    remote    = 'add rename remove set-head set-branches get-url set-url show prune update'
    rerere    = 'clear forget diff remaining status gc'
    stash     = 'push save list show apply clear drop pop create branch'
    submodule = 'add status init deinit update summary foreach sync'
    worktree  = 'add list lock move prune remove unlock'
}

# Short parameter flags per command
$script:shortGitParams = @{
    add         = 'n v f i p e u A N'
    branch      = 'd D l f m M r a v vv q t u'
    checkout    = 'q f b B t l m p'
    'cherry-pick' = 'e x r m n s S X'
    clean       = 'd f i n q e x X'
    clone       = 'l s q v n o b u c'
    commit      = 'a p C c z F m t s n e i o u v q S'
    diff        = 'p u s U z B M C D l S G O R a b w W'
    fetch       = 'a f k p n t u q v'
    log         = 'L n i E F g c c m r t'
    merge       = 'e n s X q v S m'
    mv          = 'f k n v'
    pull        = 'q v e n s X r a f k u'
    push        = 'n f u q v'
    rebase      = 'm s X S q v n C f i p x'
    reset       = 'q p'
    restore     = 's p W S q m'
    revert      = 'e m n S s X'
    rm          = 'f n r q'
    show        = 's'
    stash       = 'p k u a q'
    status      = 's b u z'
    switch      = 'c C d f m q t'
    tag         = 'a s u f d v n l m F'
}

# Long parameter names per command
$script:longGitParams = @{
    add         = 'dry-run verbose force interactive patch edit update all no-ignore-removal no-all ignore-removal intent-to-add refresh ignore-errors ignore-missing renormalize'
    branch      = 'color no-color list abbrev no-abbrev column no-column merged no-merged contains set-upstream track no-track set-upstream-to unset-upstream edit-description delete create-reflog force move all verbose quiet sort format'
    checkout    = 'quiet force ours theirs track no-track detach orphan ignore-skip-worktree-bits merge conflict patch'
    'cherry-pick' = 'edit mainline no-commit signoff gpg-sign ff allow-empty allow-empty-message keep-redundant-commits strategy strategy-option continue quit abort'
    clean       = 'force interactive dry-run quiet exclude'
    clone       = 'local no-hardlinks shared reference quiet verbose progress no-checkout bare mirror origin branch upload-pack depth shallow-since shallow-exclude single-branch no-single-branch no-tags recurse-submodules shallow-submodules jobs filter'
    commit      = 'all patch reuse-message reedit-message fixup squash reset-author short branch porcelain long null file author date message template signoff no-verify allow-empty allow-empty-message cleanup edit amend no-post-rewrite include only untracked-files verbose quiet dry-run status no-status gpg-sign no-gpg-sign'
    diff        = 'patch no-patch unified stat numstat shortstat summary patch-with-stat name-only name-status color no-color word-diff check cached staged no-index binary full-index abbrev break-rewrites find-renames find-copies find-copies-harder diff-filter'
    fetch       = 'all append depth deepen shallow-since shallow-exclude unshallow update-shallow dry-run force keep multiple prune no-tags tags recurse-submodules jobs no-recurse-submodules update-head-ok upload-pack quiet verbose progress'
    log         = 'follow no-decorate decorate source use-mailmap full-diff log-size all-match invert-grep regexp-ignore-case basic-regexp extended-regexp fixed-strings perl-regexp oneline format short medium full fuller email raw date author committer grep all-match invert-grep since until after before graph'
    merge       = 'commit no-commit edit no-edit ff no-ff ff-only log no-log signoff no-signoff stat no-stat squash no-squash strategy strategy-option verify-signatures no-verify-signatures summary no-summary quiet verbose progress no-progress allow-unrelated-histories rerere-autoupdate no-rerere-autoupdate abort continue'
    pull        = 'quiet verbose progress no-progress no-recurse-submodules recurse-submodules commit no-commit edit no-edit ff no-ff ff-only log no-log signoff no-signoff stat no-stat squash no-squash strategy strategy-option verify-signatures no-verify-signatures summary no-summary autostash no-autostash allow-unrelated-histories rebase no-rebase all append depth deepen shallow-since shallow-exclude unshallow update-shallow force keep no-tags update-head-ok upload-pack'
    push        = 'all prune mirror dry-run porcelain delete tags follow-tags signed no-signed atomic force force-with-lease no-force-with-lease repo set-upstream thin no-thin quiet verbose progress no-progress recurse-submodules no-recurse-submodules verify no-verify'
    rebase      = 'onto continue abort skip quit edit-todo keep-empty no-keep-empty rebase-merges no-rebase-merges fork-point no-fork-point ignore-whitespace whitespace committer-date-is-author-date ignore-date signoff interactive exec root autosquash no-autosquash autostash no-autostash stat no-stat verify no-verify gpg-sign'
    reset       = 'soft mixed hard merge keep quiet'
    restore     = 'source patch worktree staged quiet progress no-progress ours theirs merge conflict'
    revert      = 'edit mainline no-commit signoff gpg-sign strategy strategy-option continue quit abort'
    show        = 'format oneline short medium full fuller email raw abbrev-commit no-abbrev-commit encoding expand-tabs no-expand-tabs notes no-notes show-signature stat no-stat shortstat numstat summary name-only name-status'
    stash       = 'patch no-keep-index keep-index include-untracked all quiet message'
    status      = 'short branch show-stash porcelain long verbose untracked-files ignore-submodules ignored no-renames find-renames'
    switch      = 'create force-create detach guess no-guess force track no-track merge conflict quiet discard-changes orphan ignore-other-worktrees recurse-submodules no-recurse-submodules'
    tag         = 'annotate sign force delete verify list sort column no-column contains merged no-merged points-at message file cleanup local-user create-reflog'
    worktree    = 'force detach checkout lock reason'
}

# Commands that take refs (branches/tags) as arguments
$script:refCommands = 'cherry|cherry-pick|diff|difftool|log|merge|rebase|reflog\s+show|reset|revert|show'

# Helper: complete git subcommand names
function script:gitCommands($filter) {
    <#
    .SYNOPSIS
        Complete git subcommand and alias names matching the filter.
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    $cmds = git --list-cmds=main 2>$null
    $aliases = git config --get-regexp '^alias\.' 2>$null | ForEach-Object {
        ($_ -split '\s', 2)[0] -replace '^alias\.', ''
    }
    @($cmds) + @($aliases) | Where-Object { $_ -like "$filter*" } | Sort-Object -Unique
}

# Helper: complete branch names
function script:gitBranches($filter, $includeHEAD = $false, $prefix = '') {
    <#
    .SYNOPSIS
        Complete local branch names matching the filter.
    .PARAMETER filter
        The partial word being completed (prefix match).
    .PARAMETER includeHEAD
        When true, also offer pseudo-refs (HEAD, FETCH_HEAD, ORIG_HEAD, MERGE_HEAD).
    .PARAMETER prefix
        Optional string prepended to each completion (e.g. for 'remote/branch').
    #>

    $branches = git branch --format='%(refname:short)' 2>$null
    if ($includeHEAD) { $branches = @('HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD') + @($branches) }
    $branches | Where-Object { $_ -like "$filter*" } | ForEach-Object { "$prefix$_" }
}

# Helper: complete unique remote branch names (for checkout -b)
function script:gitRemoteUniqueBranches($filter) {
    <#
    .SYNOPSIS
        Complete remote branch names that have no matching local branch (for checkout -b).
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    $local = [System.Collections.Generic.HashSet[string]]::new(
        [string[]](git branch --format='%(refname:short)' 2>$null),
        [System.StringComparer]::OrdinalIgnoreCase)
    git branch -r --format='%(refname:short)' 2>$null | ForEach-Object {
        $short = $_ -replace '^[^/]+/', ''
        if (-not $local.Contains($short)) { $short }
    } | Where-Object { $_ -like "$filter*" }
}

# Helper: complete remote names
function script:gitRemotes($filter) {
    <#
    .SYNOPSIS
        Complete configured git remote names matching the filter.
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    git remote 2>$null | Where-Object { $_ -like "$filter*" }
}

# Helper: complete tag names
function script:gitTags($filter, $prefix = '') {
    <#
    .SYNOPSIS
        Complete git tag names matching the filter.
    .PARAMETER filter
        The partial word being completed (prefix match).
    .PARAMETER prefix
        Optional string prepended to each completion.
    #>

    git tag -l 2>$null | Where-Object { $_ -like "$filter*" } | ForEach-Object { "$prefix$_" }
}

# Helper: complete stash names
function script:gitStashes($filter) {
    <#
    .SYNOPSIS
        Complete stash reference names (e.g. stash@{0}) matching the filter.
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    git stash list --format='%gd' 2>$null | Where-Object { $_ -like "$filter*" }
}

# Helper: complete files for git add (untracked + modified)
function script:gitAddFiles($filter) {
    <#
    .SYNOPSIS
        Complete paths stageable by 'git add' (untracked or modified/deleted/added working files).
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    git status --porcelain 2>$null | ForEach-Object {
        $x = $_[0]; $y = $_[1]
        if ($x -eq '?' -or $y -eq 'M' -or $y -eq 'D' -or $y -eq 'A') {
            $_.Substring(3).Trim('"')
        }
    } | Where-Object { $_ -like "$filter*" }
}

# Helper: complete staged files for git reset
function script:gitIndexFiles($filter) {
    <#
    .SYNOPSIS
        Complete staged (index) file paths, e.g. for 'git reset'.
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    git status --porcelain 2>$null | ForEach-Object {
        $x = $_[0]
        if ($x -ne ' ' -and $x -ne '?') {
            $_.Substring(3).Trim('"')
        }
    } | Where-Object { $_ -like "$filter*" }
}

# Helper: complete modified files for git checkout --
function script:gitCheckoutFiles($filter) {
    <#
    .SYNOPSIS
        Complete modified/deleted working-tree file paths, e.g. for 'git checkout --'.
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    git status --porcelain 2>$null | ForEach-Object {
        $y = $_[1]
        if ($y -eq 'M' -or $y -eq 'D') {
            $_.Substring(3).Trim('"')
        }
    } | Where-Object { $_ -like "$filter*" }
}

# Helper: complete diff files
function script:gitDiffFiles($filter, $staged) {
    <#
    .SYNOPSIS
        Complete file paths for 'git diff' — staged files when -staged, else modified working files.
    .PARAMETER filter
        The partial word being completed (prefix match).
    .PARAMETER staged
        When true, complete staged (index) files instead of working-tree changes.
    #>

    if ($staged) {
        gitIndexFiles $filter
    } else {
        git status --porcelain 2>$null | ForEach-Object {
            $y = $_[1]
            if ($y -eq 'M' -or $y -eq 'D') {
                $_.Substring(3).Trim('"')
            }
        } | Where-Object { $_ -like "$filter*" }
    }
}

# Helper: complete subcommand sub-operations
function script:gitSubOps($cmd, $filter) {
    <#
    .SYNOPSIS
        Complete sub-operations for a git subcommand (e.g. 'stash push', 'remote add').
    .PARAMETER cmd
        The git subcommand whose sub-operations to complete (e.g. stash, remote, worktree).
    .PARAMETER filter
        The partial word being completed (prefix match).
    #>

    $ops = $script:gitSubcommands[$cmd]
    if ($ops) {
        $ops -split '\s+' | Where-Object { $_ -like "$filter*" }
    }
}

# Helper: complete long parameters
function script:gitLongParams($cmd, $filter) {
    <#
    .SYNOPSIS
        Complete '--long' option names for a git subcommand.
    .PARAMETER cmd
        The git subcommand whose long options to complete.
    .PARAMETER filter
        The partial word being completed (prefix match, without leading dashes).
    #>

    $params = $script:longGitParams[$cmd]
    if ($params) {
        $params -split '\s+' | Where-Object { $_ -like "$filter*" } | ForEach-Object { "--$_" }
    }
}

# Helper: complete short parameters
function script:gitShortParams($cmd, $filter) {
    <#
    .SYNOPSIS
        Complete '-x' short option flags for a git subcommand.
    .PARAMETER cmd
        The git subcommand whose short flags to complete.
    .PARAMETER filter
        The partial word being completed (prefix match, without leading dash).
    #>

    $params = $script:shortGitParams[$cmd]
    if ($params) {
        $params -split '\s+' | Where-Object { $_ -like "$filter*" } | ForEach-Object { "-$_" }
    }
}

# Register the native argument completer for git
$gitCmdNames = @('git')
# Also register for common git aliases
$gitAliases = Get-Alias | Where-Object { $_.Definition -eq 'git' } | ForEach-Object { $_.Name }
if ($gitAliases) { $gitCmdNames += @($gitAliases) }

Register-ArgumentCompleter -CommandName $gitCmdNames -Native -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    $padLength = $cursorPosition - $commandAst.Extent.StartOffset
    $textToComplete = $commandAst.ToString().PadRight($padLength, ' ').Substring(0, $padLength)

    # Strip leading 'git ' to get the subcommand context
    $cmd = $textToComplete -replace '^\S+\s+', ''
    $ignoreParams = '(?:\s+-(?:[aA-zZ0-9]+|-[aA-zZ0-9][aA-zZ0-9-]*)(?:=\S+)?)*'

    $completions = switch -regex ($cmd) {

        # git <subcmd> <op> (subcommand with sub-operations)
        "^(?<cmd>$($script:gitSubcommands.Keys -join '|'))\s+(?<op>\S*)$" {
            gitSubOps $Matches['cmd'] $Matches['op']
            break
        }

        # git remote <op> <remote>
        "^remote\s+(?:rename|rm|set-head|set-branches|set-url|show|prune)\s+(?<remote>\S*)$" {
            gitRemotes $Matches['remote']
            break
        }

        # git stash (show|apply|drop|pop|branch) <stash>
        "^stash\s+(?:show|apply|drop|pop|branch)\s+(?<stash>\S*)$" {
            gitStashes $Matches['stash']
            break
        }

        # git branch -d|-D|-m|-M <branch> or git branch <name> <start-point>
        "^branch${ignoreParams}\s+(?<branch>\S*)$" {
            gitBranches $Matches['branch']
            break
        }

        # git push remote <ref>:<branch>
        "^push${ignoreParams}\s+(?<remote>[^\s-]\S*).*\s+(?<force>\+?)(?<ref>[^\s\:]*\:)(?<branch>\S*)$" {
            git branch -r --format='%(refname:short)' 2>$null |
                Where-Object { $_ -match "^$($Matches['remote'])/" } |
                ForEach-Object { "$($Matches['force'])$($Matches['ref'])$($_ -replace '^[^/]+/', '')" } |
                Where-Object { $_ -like "*$($Matches['branch'])*" }
            break
        }

        # git push/pull/fetch remote <ref>
        "^(?:push|pull)${ignoreParams}\s+(?<remote>[^\s-]\S*).*\s+(?<force>\+?)(?<ref>[^\s\:]*)$" {
            gitBranches $Matches['ref'] -prefix $Matches['force']
            gitTags $Matches['ref'] -prefix $Matches['force']
            break
        }

        # git push/pull/fetch <remote>
        "^(?:push|pull|fetch)${ignoreParams}\s+(?<remote>\S*)$" {
            gitRemotes $Matches['remote']
            break
        }

        # git reset HEAD <path>
        "^reset.*\s+HEAD(?:\s+--)?\s+(?<path>\S*)$" {
            gitIndexFiles $Matches['path']
            break
        }

        # git add <path>
        "^add${ignoreParams}\s+(?<files>\S*)$" {
            gitAddFiles $Matches['files']
            break
        }

        # git checkout -- <path>
        "^checkout.*\s+--\s+(?<files>\S*)$" {
            gitCheckoutFiles $Matches['files']
            break
        }

        # git restore -s <ref> / --source=<ref>
        "^restore.*\s+(?:-s\s*|(?<source>--source=))(?<ref>\S*)$" {
            gitBranches $Matches['ref'] $true $Matches['source']
            gitTags $Matches['ref']
            break
        }

        # git restore <path>
        "^restore${ignoreParams}\s+(?<files>\S*)$" {
            gitCheckoutFiles $Matches['files']
            break
        }

        # git rm <path>
        "^rm${ignoreParams}\s+(?<files>\S*)$" {
            gitIndexFiles $Matches['files']
            break
        }

        # git diff/difftool <path>
        "^(?:diff|difftool)(?:.*\s+(?<staged>--cached|--staged)|.*)\s+(?<files>\S*)$" {
            gitDiffFiles $Matches['files'] $Matches['staged']
            break
        }

        # git checkout/switch <ref>
        "^(?:checkout|switch)${ignoreParams}\s+(?<ref>\S*)$" {
            & {
                gitBranches $Matches['ref'] $true
                gitRemoteUniqueBranches $Matches['ref']
                gitTags $Matches['ref']
            } | Select-Object -Unique
            break
        }

        # git worktree add <path> <ref>
        "^worktree\s+add\s+\S+\s+(?<ref>\S*)$" {
            gitBranches $Matches['ref']
            break
        }

        # git <ref-cmd> <ref> (cherry-pick, merge, rebase, log, show, etc.)
        "^(?:$($script:refCommands))${ignoreParams}\s+(?<ref>\S*)$" {
            gitBranches $Matches['ref'] $true
            gitTags $Matches['ref']
            break
        }

        # git help <cmd>
        "^help\s+(?<cmd>\S*)$" {
            gitCommands $Matches['cmd']
            break
        }

        # git <cmd> --<param> (long parameter)
        "^(?<cmd>\S+).*\s+--(?<param>\S*)$" {
            gitLongParams $Matches['cmd'] $Matches['param']
            break
        }

        # git <cmd> -<param> (short parameter)
        "^(?<cmd>\S+).*\s+-(?<param>[^-]\S*)$" {
            gitShortParams $Matches['cmd'] $Matches['param']
            break
        }

        # git <cmd> (top-level command completion)
        "^(?<cmd>\S*)$" {
            gitCommands $Matches['cmd']
            break
        }
    }

    $completions | ForEach-Object {
        [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
    }
}