git-aliases-extra.psm1
|
# =================================================================== # git-aliases-extra.psm1 # # Extends posh-git and git-aliases with custom functions and # adds robust tab completion for all git aliases. # # Mainly inspired by: https://github.com/zh30/zsh-shortcut-git # =================================================================== # --- Custom Helper Functions --- function Test-InGitRepo { try { git rev-parse --is-inside-work-tree *> $null $true } catch { $false } } function Test-GitInProgress { if (-not (Test-InGitRepo)) { return $false } $gitDir = git rev-parse --git-dir $inProgressFiles = @("MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "BISECT_LOG") foreach ($file in $inProgressFiles) { if (Test-Path (Join-Path $gitDir $file)) { return $true } } $false } function Test-WorkingTreeClean { if (-not (Test-InGitRepo)) { return $false } # Checks for both unstaged and staged changes. & git diff --quiet --exit-code $isClean = ($LASTEXITCODE -eq 0) & git diff --cached --quiet --exit-code $isClean = $isClean -and ($LASTEXITCODE -eq 0) return $isClean } function Get-CurrentBranch { if (Test-InGitRepo) { $branch = git rev-parse --abbrev-ref HEAD if ($branch -ne "HEAD") { return $branch.Trim() } } return $null } function Test-GitRefExists { param( [Parameter(Mandatory=$true)] [string]$RefName ) & git show-ref --verify --quiet $RefName return ($LASTEXITCODE -eq 0) } function Convert-ToPowerShellBranchCompletionText { param( [Parameter(Mandatory = $true)] [string]$BranchName ) if ($BranchName.StartsWith('#')) { $escaped = $BranchName -replace "'", "''" return "'$escaped'" } return $BranchName } function Get-GitLongOptionCompletions { param( [Parameter(Mandatory = $true)] [string]$SubCommandLine, [Parameter(Mandatory = $true)] [string]$WordToComplete ) if (-not $WordToComplete.StartsWith('-')) { return @() } try { $commandParts = @($SubCommandLine -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($commandParts.Count -eq 0) { return @() } $helpText = (& git @commandParts -h 2>&1 | Out-String) if (-not $helpText) { return @() } $rawTokens = [regex]::Matches($helpText, '--[^\s,]+') | ForEach-Object { $_.Value } | Sort-Object -Unique $expanded = foreach ($token in $rawTokens) { $clean = $token.Trim().TrimEnd(',', ';') $clean = $clean -replace '<.*$', '' if ($clean -match '^--\[no-\](.+)$') { $suffix = $matches[1] -replace '\[.*$', '' if ($suffix) { "--$suffix" "--no-$suffix" } continue } $clean = $clean -replace '\[.*$', '' if ($clean.StartsWith('--')) { $clean } } $options = $expanded | Sort-Object -Unique | Where-Object { $_ -like "$WordToComplete*" } if (-not $options) { return @() } return $options | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) } } catch { return @() } } function Format-GitAliasDefinitionSafe { param( [AllowEmptyString()] [string]$Definition ) if ([string]::IsNullOrWhiteSpace($Definition)) { return '' } $definitionLines = $Definition.Trim() -split "`r?`n" | ForEach-Object { $rawLine = [string]$_ $line = $rawLine.TrimEnd() if ($rawLine -match "^`t") { if ($line.Length -ge 1) { return $line.Substring(1) } return '' } if ($rawLine -match '^ ') { if ($line.Length -ge 4) { return $line.Substring(4) } return '' } return $line } return ($definitionLines -join "`n") } function Convert-ToPowerShellPathCompletionText { param( [Parameter(Mandatory = $true)] [string]$PathValue ) if ($PathValue -match "[\s']") { $escaped = $PathValue -replace "'", "''" return "'$escaped'" } return $PathValue } function Get-GitWorktreePaths { if (-not (Test-InGitRepo)) { return @() } try { $lines = @(& git worktree list --porcelain 2>$null) if ($LASTEXITCODE -ne 0 -or -not $lines) { return @() } $paths = $lines | Where-Object { $_ -like 'worktree *' } | ForEach-Object { $_.Substring(9).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique return @($paths) } catch { return @() } } function Get-GitWorktreePathCompletions { param( [Parameter(Mandatory = $true)] [string]$CommandName, [Parameter(Mandatory = $true)] [string]$Line, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$WordToComplete ) $tokens = $null $errors = $null [System.Management.Automation.Language.Parser]::ParseInput($Line, [ref]$tokens, [ref]$errors) | Out-Null $tokenTexts = @( $tokens | Where-Object { $_.Kind.ToString() -notin @('EndOfInput', 'NewLine') } | ForEach-Object { $_.Text } ) if ($tokenTexts.Count -eq 0) { return @() } $action = '' $argumentTokens = @() switch ($CommandName) { 'gwtr' { $action = 'remove' if ($tokenTexts.Count -gt 1) { $argumentTokens = @($tokenTexts[1..($tokenTexts.Count - 1)]) } } 'gwtm' { $action = 'move' if ($tokenTexts.Count -gt 1) { $argumentTokens = @($tokenTexts[1..($tokenTexts.Count - 1)]) } } 'gwt' { if ($tokenTexts.Count -lt 2) { return @() } $action = $tokenTexts[1] if ($tokenTexts.Count -gt 2) { $argumentTokens = @($tokenTexts[2..($tokenTexts.Count - 1)]) } } default { return @() } } if ($action -notin @('remove', 'move')) { return @() } $nonOptionArgCount = @($argumentTokens | Where-Object { -not $_.StartsWith('-') }).Count $hasWord = -not [string]::IsNullOrWhiteSpace($WordToComplete) $completeFirstPath = $nonOptionArgCount -eq 0 -or ($nonOptionArgCount -eq 1 -and $hasWord) if (-not $completeFirstPath) { return @() } $prefix = $WordToComplete.Trim("'", '"') $worktreePaths = Get-GitWorktreePaths if (-not $worktreePaths -or $worktreePaths.Count -eq 0) { return @() } $filteredPaths = if ([string]::IsNullOrWhiteSpace($prefix)) { $worktreePaths } else { $worktreePaths | Where-Object { $_ -like "$prefix*" -or (Split-Path -Path $_ -Leaf) -like "$prefix*" } } if (-not $filteredPaths) { return @() } return @($filteredPaths | Sort-Object -Unique | ForEach-Object { $pathValue = [string]$_ $completionText = Convert-ToPowerShellPathCompletionText -PathValue $pathValue [System.Management.Automation.CompletionResult]::new( $completionText, $pathValue, 'ParameterValue', "git worktree path: $pathValue" ) }) } function Get-GitAliasEntries { param( [ValidateSet('all', 'base', 'extras')] [string]$Source = 'all' ) $blacklist = @( 'Get-Git-CurrentBranch', 'Remove-Alias', 'Format-AliasDefinition', 'Get-Git-Aliases', 'Write-Host-Deprecated', 'Format-GitAliasDefinitionSafe', 'Get-GitAliasEntries' ) $modulePriority = @{ 'git-aliases-extra' = 0 'git-aliases' = 1 } $moduleSource = @{ 'git-aliases-extra' = 'extras' 'git-aliases' = 'base' } $entries = @() $aliasNamePattern = '^g[0-9A-Za-z!]+$' foreach ($moduleName in @('git-aliases-extra', 'git-aliases')) { $commands = Get-Command -Module $moduleName -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin $blacklist -and $_.CommandType -in @('Function', 'Alias') -and $_.Name -match $aliasNamePattern } foreach ($command in $commands) { $definition = switch ($command.CommandType) { 'Alias' { "Alias -> $($command.Definition)" } default { Format-GitAliasDefinitionSafe -Definition $command.Definition } } if ([string]::IsNullOrWhiteSpace($definition)) { continue } $entries += [PSCustomObject]@{ Name = $command.Name Definition = $definition ModuleName = $moduleName Source = $moduleSource[$moduleName] Priority = $modulePriority[$moduleName] } } } if ($Source -ne 'all') { return $entries | Where-Object { $_.Source -eq $Source } | Sort-Object Name } $ordered = $entries | Sort-Object Priority, Name $seen = @{} $result = foreach ($entry in $ordered) { if (-not $seen.ContainsKey($entry.Name)) { $seen[$entry.Name] = $true $entry } } return @($result) } function Get-Git-Aliases { [CmdletBinding()] param( [AllowEmptyString()] [string]$Alias, [switch]$Base, [switch]$Extras ) if ($Base -and $Extras) { Write-Error "Use either -Base or -Extras, not both." -ErrorAction Stop } $source = 'all' if ($Base) { $source = 'base' } if ($Extras) { $source = 'extras' } $Alias = if ($null -eq $Alias) { '' } else { $Alias.Trim() } $aliases = Get-GitAliasEntries -Source $source if (-not ([string]::IsNullOrWhiteSpace($Alias))) { $foundAlias = $aliases | Where-Object { $_.Name -eq $Alias } | Select-Object -First 1 if ($null -eq $foundAlias) { $scopeText = switch ($source) { 'base' { ' in base aliases' } 'extras' { ' in extras aliases' } default { '' } } Write-Error ("Alias '{0}' not found{1}." -f $Alias, $scopeText) -ErrorAction Stop } return $foundAlias.Definition } if ($source -in @('base', 'extras')) { return $aliases | Select-Object Name, Definition | Format-Table -AutoSize -Wrap } return $aliases | Select-Object Name, Source, Definition | Format-Table -AutoSize -Wrap } # --- Custom Git Command Functions --- function UpMerge { [CmdletBinding()] param( [string]$Src = "origin/main", [switch]$AllowDirty, [switch]$AllowInProgress, [switch]$NoFetch, [switch]$NoFF ) if (-not (Test-InGitRepo)) { throw "Not a git repository." } $tgt = Get-CurrentBranch; if (-not $tgt) { throw "Detached HEAD. Cannot merge." } if (-not $AllowInProgress -and (Test-GitInProgress)) { throw "Another git operation is in progress." } if (-not $AllowDirty -and -not (Test-WorkingTreeClean)) { throw "Working tree is not clean." } if (-not $NoFetch) { git fetch --all --prune; if ($LASTEXITCODE -ne 0) { throw "git fetch failed." } } $msg = "chore(sync): merge $Src into $tgt" if ($NoFF) { git merge --no-ff --no-edit $Src -m $msg } else { git merge --no-edit $Src -m $msg } } function UpRebase { [CmdletBinding()] param( [string]$Src = "origin/main", [switch]$AllowDirty, [switch]$AllowInProgress, [switch]$NoFetch, [switch]$Autostash ) if (-not (Test-InGitRepo)) { throw "Not a git repository." } $tgt = Get-CurrentBranch; if (-not $tgt) { throw "Detached HEAD. Cannot rebase." } if (-not $AllowInProgress -and (Test-GitInProgress)) { throw "Another git operation is in progress." } if (-not $AllowDirty -and -not (Test-WorkingTreeClean)) { throw "Working tree is not clean." } if (-not $NoFetch) { git fetch --all --prune; if ($LASTEXITCODE -ne 0) { throw "git fetch failed." } } if ($Autostash) { git rebase --autostash $Src } else { git rebase $Src } } function gapt { git apply --3way @args } function gcor { git checkout --recurse-submodules @args } function gdct { git describe --tags (git rev-list --tags --max-count=1) } function gdt { git diff-tree --no-commit-id --name-only -r @args } function gdnolock { git diff @args ":(exclude)package-lock.json" ":(exclude)*.lock" } function gdv { git diff -w @args | Out-String | less } function gfo { git fetch origin @args } function gwt { git worktree @args } function gwta { git worktree add @args } function gwtl { git worktree list @args } function gwtm { git worktree move @args } function gwtr { git worktree remove @args } function gwtp { git worktree prune @args } function glp { param([string]$format) if($format){ git log --pretty=$format } else { git log } } function gmtl { git mergetool --no-prompt @args } function gmtlvim{ git mergetool --no-prompt --tool=vimdiff @args } function gtv { git tag | Sort-Object { $_ -as [version] } } function gtl { param($p='') git tag --sort=-v:refname -n -l "$p*" } function gwip { git add -A; git rm (git ls-files --deleted) 2>$null; git commit --no-verify --no-gpg-sign -m "--wip-- [skip ci]" } function gunwip { if (git log -n 1 | Select-String -Quiet -- "--wip--") { git reset HEAD~1 } } function grsh { git reset --soft HEAD~1 } function gccd { param([Parameter(ValueFromRemainingArguments=$true)][string[]]$rest) git clone --recurse-submodules @rest $last = if ($rest.Count) { $rest[-1] } else { '' } if ($last -match '\.git$') { $last = $last -replace '\.git$','' } if ($last) { $dirName = Split-Path $last -Leaf if (Test-Path $dirName) { Set-Location $dirName } } } function grl { git reflog @args } # Get commit hash - returns the hash of HEAD or HEAD~n # Usage: ghash [steps] [-Short] # ghash - full hash of HEAD # ghash 3 - full hash of HEAD~3 # ghash -Short - short hash of HEAD # ghash 3 -Short - short hash of HEAD~3 function ghash { [CmdletBinding()] param( [Parameter(Position=0)] [int]$StepsBack = 0, [Alias('s')] [switch]$Short ) if (-not (Test-InGitRepo)) { Write-Error "Not a git repository." -ErrorAction Stop return } $ref = if ($StepsBack -eq 0) { "HEAD" } else { "HEAD~$StepsBack" } try { if ($Short) { $hash = git rev-parse --short $ref 2>$null } else { $hash = git rev-parse $ref 2>$null } if ($LASTEXITCODE -eq 0 -and $hash) { $hash.Trim() } else { Write-Error "Invalid reference: $ref" -ErrorAction Stop } } catch { Write-Error "Failed to get commit hash: $_" -ErrorAction Stop } } function gfp { [CmdletBinding()] param( [Parameter(Position = 0)] [string]$TargetBranch, [Parameter(Position = 1)] [string]$OutputFile = 'series.mbox' ) if (-not (Test-InGitRepo)) { throw "Not a git repository." } $defaultRemote = git config --get checkout.defaultRemote 2>$null if (-not $defaultRemote) { $defaultRemote = 'origin' } if (-not $TargetBranch) { if (Test-GitRefExists "refs/remotes/$defaultRemote/main") { $TargetBranch = 'main' } elseif (Test-GitRefExists "refs/remotes/$defaultRemote/master") { $TargetBranch = 'master' } else { $remoteHead = git symbolic-ref --quiet "refs/remotes/$defaultRemote/HEAD" 2>$null if ($LASTEXITCODE -eq 0 -and $remoteHead) { $prefix = "refs/remotes/$defaultRemote/" if ($remoteHead.StartsWith($prefix)) { $TargetBranch = $remoteHead.Substring($prefix.Length).Trim() } } } if (-not $TargetBranch) { $TargetBranch = 'main' } } $range = "$defaultRemote/$TargetBranch..HEAD" $resolvedOutput = if ([IO.Path]::IsPathRooted($OutputFile)) { $OutputFile } else { Join-Path (Get-Location) $OutputFile } $outputDir = Split-Path -Parent $resolvedOutput if ($outputDir -and -not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } $stderrFile = [IO.Path]::GetTempFileName() try { $process = Start-Process -FilePath 'git' ` -ArgumentList @('format-patch', '--cover-letter', '--stat', '--stdout', $range) ` -NoNewWindow ` -PassThru ` -Wait ` -RedirectStandardOutput $resolvedOutput ` -RedirectStandardError $stderrFile $stderrText = Get-Content -LiteralPath $stderrFile -Raw -ErrorAction SilentlyContinue if ($stderrText) { $stderrText = $stderrText.Trim() if ($stderrText) { Write-Host $stderrText } } if ($process.ExitCode -ne 0) { Remove-Item -LiteralPath $resolvedOutput -Force -ErrorAction SilentlyContinue throw "git format-patch failed for '$range' (exit code: $($process.ExitCode))." } return $resolvedOutput } finally { Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue } } function gsw { [CmdletBinding()] param( [Parameter(ValueFromRemainingArguments=$true)] [string[]]$rest ) # Keep native behavior for advanced invocations (flags, start-points, etc.). if ($rest.Count -ne 1 -or $rest[0].StartsWith('-')) { git switch @rest return } if (-not (Test-InGitRepo)) { git switch @rest return } $target = $rest[0].Trim() if (-not $target) { git switch @rest return } # Prefer an existing local branch first. if (Test-GitRefExists "refs/heads/$target") { git switch $target return } # If only remote exists, create a tracking local branch explicitly. # This avoids ambiguous SHA/ref parsing for numeric names like "8695". $defaultRemote = git config --get checkout.defaultRemote 2>$null if (-not $defaultRemote) { $defaultRemote = 'origin' } if (Test-GitRefExists "refs/remotes/$defaultRemote/$target") { git switch --track "$defaultRemote/$target" return } git switch @rest } function gswc { git switch -c @args } # Alias for shorter command # --- Set Aliases for Custom Functions --- Set-Alias gum UpMerge Set-Alias gur UpRebase Set-Alias gh ghash # =================================================================== # Tab Completion Registration # =================================================================== function Register-GitAliasCompletion { # 1. Build a map of alias functions to their git subcommands $script:gitAliasMap = @{} $aliasRegex = '^\s*git\s+([\w-]+)\s+' # Get all aliases from the 'git-aliases' module Get-Command -Module git-aliases | ForEach-Object { $definition = (Get-Content Function:\$($_.Name)).ToString() if ($definition -match $aliasRegex) { $script:gitAliasMap[$_.Name] = $matches[1] } } # Add custom aliases from this module without module-name lookups # to avoid self-import recursion during module initialization. $moduleName = $ExecutionContext.SessionState.Module.Name Get-Command -CommandType Function | Where-Object { $_.ModuleName -eq $moduleName } | ForEach-Object { $func = $_ $definition = $func.ScriptBlock.ToString() if ($definition -match $aliasRegex) { $subCommand = $matches[1] $script:gitAliasMap[$func.Name] = $subCommand } } # Manually add complex functions that don't fit the regex pattern $script:gitAliasMap['gum'] = 'merge' $script:gitAliasMap['gur'] = 'rebase' $script:gitAliasMap['gccd'] = 'clone' $script:gitAliasMap['gsw'] = 'switch' $script:gitAliasMap['gwt'] = 'worktree' $script:gitAliasMap['gwta'] = 'worktree add' $script:gitAliasMap['gwtl'] = 'worktree list' $script:gitAliasMap['gwtm'] = 'worktree move' $script:gitAliasMap['gwtr'] = 'worktree remove' $script:gitAliasMap['gwtp'] = 'worktree prune' if ($script:gitAliasMap.Count -eq 0) { Write-Warning "No git alias functions were found to register for completion." return } # 2. Create the proxy completer script block $proxyCompleter = { param($wordToComplete, $commandAst, $cursorPosition) $commandName = $commandAst.CommandElements[0].Value $subCommandLine = [string]$script:gitAliasMap[$commandName] $primarySubCommand = ($subCommandLine -split '\s+')[0] # Reconstruct the command line as if 'git <subcommand>' was typed $line = $commandAst.Extent.Text # CommandAst extent drops trailing whitespace, but completion context # depends on it (e.g. "gsw " vs "gsw"). Restore it using cursor offset. if ($cursorPosition -gt $line.Length) { $line += (' ' * ($cursorPosition - $line.Length)) } $gitLine = $line -replace "^$commandName", "git $subCommandLine" $offset = ("git $subCommandLine").Length - $commandName.Length $gitCursorPosition = $cursorPosition + $offset if ($gitCursorPosition -lt 0) { $gitCursorPosition = 0 } if ($gitCursorPosition -gt $gitLine.Length) { $gitCursorPosition = $gitLine.Length } if ($primarySubCommand -eq 'worktree' -and $wordToComplete -notlike '-*') { $worktreeCompletions = Get-GitWorktreePathCompletions ` -CommandName $commandName ` -Line $line ` -WordToComplete $wordToComplete if ($worktreeCompletions -and $worktreeCompletions.Count -gt 0) { return $worktreeCompletions } } # Delegate to native completion for `git <subcommand> ...`. # This preserves completion for long options (e.g. --track, --detach) # and other git-specific argument completion behavior. try { $nativeCompletion = [System.Management.Automation.CommandCompletion]::CompleteInput( $gitLine, $gitCursorPosition, $null ) if ($null -ne $nativeCompletion -and $null -ne $nativeCompletion.CompletionMatches -and $nativeCompletion.CompletionMatches.Count -gt 0) { $delegateMatches = @($nativeCompletion.CompletionMatches) if ($primarySubCommand -in @('checkout', 'switch', 'merge', 'rebase', 'branch', 'reset', 'revert')) { $delegateMatches = @($nativeCompletion.CompletionMatches | ForEach-Object { if ($null -eq $_) { return } $completionText = $_.CompletionText if ($completionText -is [string] -and $completionText.StartsWith('#')) { $safeText = Convert-ToPowerShellBranchCompletionText -BranchName $completionText return [System.Management.Automation.CompletionResult]::new( $safeText, $_.ListItemText, $_.ResultType, $_.ToolTip ) } return $_ }) } if ($primarySubCommand -eq 'worktree' -and $wordToComplete -notlike '-*') { $worktreeCompletions = Get-GitWorktreePathCompletions ` -CommandName $commandName ` -Line $line ` -WordToComplete $wordToComplete if ($worktreeCompletions -and $worktreeCompletions.Count -gt 0) { return $worktreeCompletions } } if ($wordToComplete -like '-*') { $optionCompletions = Get-GitLongOptionCompletions -SubCommandLine $subCommandLine -WordToComplete $wordToComplete if ($optionCompletions -and $optionCompletions.Count -gt 0) { $combined = @() $seen = @{} foreach ($item in @($delegateMatches + $optionCompletions)) { if ($null -eq $item) { continue } $key = $item.CompletionText if (-not $seen.ContainsKey($key)) { $combined += $item $seen[$key] = $true } } return $combined } } return $delegateMatches } } catch { Write-Warning "Native completion delegation failed for alias '$commandName'." } # Long option fallback when native completion isn't available. $optionCompletions = Get-GitLongOptionCompletions -SubCommandLine $subCommandLine -WordToComplete $wordToComplete if ($optionCompletions -and $optionCompletions.Count -gt 0) { return $optionCompletions } if ($primarySubCommand -eq 'worktree' -and $wordToComplete -notlike '-*') { $worktreeCompletions = Get-GitWorktreePathCompletions ` -CommandName $commandName ` -Line $line ` -WordToComplete $wordToComplete if ($worktreeCompletions -and $worktreeCompletions.Count -gt 0) { return $worktreeCompletions } } # Final fallback when delegated completion is unavailable if ($primarySubCommand -in @('checkout', 'switch', 'merge', 'rebase', 'branch', 'reset', 'revert')) { try { $branches = git branch -a --format='%(refname:short)' | ForEach-Object { $_ -replace '^remotes/origin/', '' } | Sort-Object -Unique | Where-Object { $_ -like "$wordToComplete*" } if ($branches) { return $branches | ForEach-Object { $branchName = $_ $safeText = Convert-ToPowerShellBranchCompletionText -BranchName $branchName [System.Management.Automation.CompletionResult]::new($safeText, $branchName, 'ParameterValue', $branchName) } } } catch { return @() } } # Return nothing if no completions are found return @() } # 3. Register the completer for every alias found foreach ($aliasName in $script:gitAliasMap.Keys) { # Ensure we only register for commands that actually exist if (Get-Command $aliasName -ErrorAction SilentlyContinue) { Register-ArgumentCompleter -CommandName $aliasName -ScriptBlock $proxyCompleter } } Write-Host "Git alias tab completion is now active for $($script:gitAliasMap.Count) aliases." -ForegroundColor Cyan } # --- Run Registration and Export Members --- # This runs when the module is imported Register-GitAliasCompletion # Export all public functions and aliases for use in the shell Export-ModuleMember -Function * -Alias * |