1
Fork 0
mirror of https://github.com/RGBCube/nu_scripts synced 2025-08-01 06:37:46 +00:00

Improve completer speed for git checkout (#1054)

Improvement:
- Faster to give result (calling `git` less, telling `git` to return
simpler-to-parse output).
- Context aware. For example:
+ After `git checkout branch-name`, the rest arguments must be file
paths (`git checkout` only accepts one "branch").
  + After `--` are file paths. `git checkout a-branch -- a-file`

The current completer for `git checkout` is slow because:
- Running `git` too many times.
- Parse many results and end up not use.


![image](https://github.com/user-attachments/assets/48b8542b-0080-4962-a660-2a13e9cb76ea)


![image](https://github.com/user-attachments/assets/8e55e3cf-70bc-404a-b303-7a13d811b5fd)
This commit is contained in:
Nguyễn Hồng Quân 2025-02-22 20:29:04 +07:00 committed by GitHub
parent d8a3d123ca
commit 1e7fdd4427
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,3 +1,131 @@
module git-completion-utils {
export const GIT_SKIPABLE_FLAGS = ['-v', '--version', '-h', '--help', '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--bare']
# Helper function to append token if non-empty
def append-non-empty [token: string]: list<string> -> list<string> {
if ($token | is-empty) { $in } else { $in | append $token }
}
# Split a string to list of args, taking quotes into account.
# Code is copied and modified from https://github.com/nushell/nushell/issues/14582#issuecomment-2542596272
export def args-split []: string -> list<string> {
# Define our states
const STATE_NORMAL = 0
const STATE_IN_SINGLE_QUOTE = 1
const STATE_IN_DOUBLE_QUOTE = 2
const STATE_ESCAPE = 3
const WHITESPACES = [" " "\t" "\n" "\r"]
# Initialize variables
mut state = $STATE_NORMAL
mut current_token = ""
mut result: list<string> = []
mut prev_state = $STATE_NORMAL
# Process each character
for char in ($in | split chars) {
if $state == $STATE_ESCAPE {
# Handle escaped character
$current_token = $current_token + $char
$state = $prev_state
} else if $char == '\' {
# Enter escape state
$prev_state = $state
$state = $STATE_ESCAPE
} else if $state == $STATE_NORMAL {
if $char == "'" {
$state = $STATE_IN_SINGLE_QUOTE
} else if $char == '"' {
$state = $STATE_IN_DOUBLE_QUOTE
} else if ($char in $WHITESPACES) {
# Whitespace in normal state means token boundary
$result = $result | append-non-empty $current_token
$current_token = ""
} else {
$current_token = $current_token + $char
}
} else if $state == $STATE_IN_SINGLE_QUOTE {
if $char == "'" {
$state = $STATE_NORMAL
} else {
$current_token = $current_token + $char
}
} else if $state == $STATE_IN_DOUBLE_QUOTE {
if $char == '"' {
$state = $STATE_NORMAL
} else {
$current_token = $current_token + $char
}
}
}
# Handle the last token
$result = $result | append-non-empty $current_token
# Return the result
$result
}
# Get changed files which can be restored by `git checkout --`
export def get-changed-files []: nothing -> list<string> {
^git status -uno --porcelain=2 | lines
| where $it =~ '^1 [.MD]{2}'
| each { split row ' ' -n 9 | last }
}
# Get files which can be retrieved from a branch/commit by `git checkout <tree-ish>`
export def get-checkoutable-files []: nothing -> list<string> {
# Relevant statuses are .M", "MM", "MD", ".D", "UU"
^git status -uno --porcelain=2 | lines
| where $it =~ '^1 ([.MD]{2}|UU)'
| each { split row ' ' -n 9 | last }
}
export def get-all-git-branches []: nothing -> list<string> {
^git branch -a --format '%(refname:lstrip=2)%09%(upstream:lstrip=2)' | lines | str trim | filter { not ($in ends-with 'HEAD' ) }
}
# Extract remote branches which do not have local counterpart
export def extract-remote-branches-nonlocal-short [current: string]: list<string> -> list<string> {
# Input is a list of lines, like:
# ╭────┬────────────────────────────────────────────────╮
# │ 0 │ feature/awesome-1 origin/feature/awesome-1 │
# │ 1 │ fix/bug-1 origin/fix/bug-1 │
# │ 2 │ main origin/main │
# │ 3 │ origin/HEAD │
# │ 4 │ origin/feature/awesome-1 │
# │ 5 │ origin/fix/bug-1 │
# │ 6 │ origin/feature/awesome-2 │
# │ 7 │ origin/main │
# │ 8 │ upstream/main │
# │ 9 │ upstream/awesome-3 │
# ╰────┴────────────────────────────────────────────────╯
# and we pick ['feature/awesome-2', 'awesome-3']
let lines = $in
let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' }
let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") }
let tracked_remotes = $branches | find --no-highlight "\t" | each { split row "\t" -n 2 | get 1 }
let floating_remotes = $lines | filter { "\t" not-in $in and $in not-in $tracked_remotes }
$floating_remotes | each {
let v = $in | split row -n 2 '/' | get 1
if $v != $current { [$v] } else []
} | flatten
}
export def extract-mergable-sources [current: string]: list<string> -> list<record<value: string, description: string>> {
let lines = $in
let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' }
let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") }
let git_table: list<record<n: string, u: string>> = $branches | each {|v| if "\t" in $v { $v | split row "\t" -n 2 | {n: $in.0, u: $in.1 } } else {n: $v, u: null } }
let siblings = $git_table | where u == null and n starts-with 'origin/' | get n | str substring 7..
let remote_branches = $git_table | filter {|r| $r.u == null and not ($r.n starts-with 'origin/') } | get n
[...($siblings | wrap value | insert description Local), ...($remote_branches | wrap value | insert description Remote)]
}
# Get local branches, remote branches which can be passed to `git merge`
export def get-mergable-sources []: nothing -> list<record<value: string, description: string>> {
let current = (^git branch --show-current) # Can be empty if in detached HEAD
(get-all-git-branches | extract-mergable-sources $current)
}
}
def "nu-complete git available upstream" [] { def "nu-complete git available upstream" [] {
^git branch --no-color -a | lines | each { |line| $line | str replace '* ' "" | str trim } ^git branch --no-color -a | lines | each { |line| $line | str replace '* ' "" | str trim }
@ -32,56 +160,53 @@ def "nu-complete git remote branches with prefix" [] {
^git branch --no-color -r | lines | parse -r '^\*?(\s*|\s*\S* -> )(?P<branch>\S*$)' | get branch | uniq ^git branch --no-color -r | lines | parse -r '^\*?(\s*|\s*\S* -> )(?P<branch>\S*$)' | get branch | uniq
} }
# Yield remote branches *without* prefix which do not have a local counterpart.
# E.g. `upstream/feature-a` as `feature-a` to checkout and track in one command
# with `git checkout` or `git switch`.
def "nu-complete git remote branches nonlocal without prefix" [] {
# Get regex to strip remotes prefixes. It will look like `(origin|upstream)`
# for the two remotes `origin` and `upstream`.
let remotes_regex = (["(", ((nu-complete git remotes | each {|r| [$r, '/'] | str join}) | str join "|"), ")"] | str join)
let local_branches = (nu-complete git local branches)
^git branch --no-color -r | lines | parse -r (['^[\* ]+', $remotes_regex, '?(?P<branch>\S+)'] | flatten | str join) | get branch | uniq | where {|branch| $branch != "HEAD"} | where {|branch| $branch not-in $local_branches }
}
# Yield local and remote branch names which can be passed to `git merge` # Yield local and remote branch names which can be passed to `git merge`
def "nu-complete git mergable sources" [] { def "nu-complete git mergable sources" [] {
let current = (^git branch --show-current) use git-completion-utils *
let long_current = $'origin/($current)' (get-mergable-sources)
let git_table = ^git branch -a --format '%(refname:lstrip=2)%09%(upstream:lstrip=2)' | lines | str trim | where { ($in != $long_current) and not ($in starts-with $"($current)\t") and not ($in ends-with 'HEAD') } | each {|v| if "\t" in $v { $v | split row "\t" -n 2 | {'n': $in.0, 'u': $in.1 } } else {'n': $v, 'u': null } }
let siblings = $git_table | where u == null and n starts-with 'origin/' | get n | str substring 7..
let remote_branches = $git_table | filter {|r| $r.u == null and not ($r.n starts-with 'origin/') } | get n
[...($siblings | wrap value | insert description Local), ...($remote_branches | wrap value | insert description Remote)]
} }
def "nu-complete git switch" [] { def "nu-complete git switch" [] {
(nu-complete git local branches) use git-completion-utils *
| parse "{value}" let current = (^git branch --show-current) # Can be empty if in detached HEAD
| insert description "local branch" let local_branches = ^git branch --format '%(refname:short)' | lines | filter { $in != $current } | wrap value | insert description 'Local branch'
| append (nu-complete git remote branches nonlocal without prefix let remote_branches = (get-all-git-branches | extract-remote-branches-nonlocal-short $current) | wrap value | insert description 'Remote branch'
| parse "{value}" [...$local_branches, ...$remote_branches]
| insert description "remote branch")
} }
def "nu-complete git checkout" [] { def "nu-complete git checkout" [context: string, position?:int] {
let table_of_checkouts = (nu-complete git local branches) use git-completion-utils *
| parse "{value}" let preceding = $context | str substring ..$position
| insert description "local branch" # See what user typed before, like 'git checkout a-branch a-path'.
| append (nu-complete git remote branches nonlocal without prefix # We exclude some flags from previous tokens, to detect if a branch name has been used as the first argument.
| parse "{value}" # FIXME: This method is still naive, though.
| insert description "remote branch") let prev_tokens = $preceding | str trim | args-split | where ($it not-in $GIT_SKIPABLE_FLAGS)
| append (nu-complete git remote branches with prefix # In these scenarios, we suggest only file paths, not branch:
| parse "{value}" # - After '--'
| insert description "remote branch") # - First arg is a branch
| append (nu-complete git files | where description != "Untracked" | select value | insert description "git file") # If before '--' is just 'git checkout' (or its alias), we suggest "dirty" files only (user is about to reset file).
| append (nu-complete git commits all) if $prev_tokens.2? == '--' {
return (get-changed-files)
return { }
if '--' in $prev_tokens {
return (get-checkoutable-files)
}
# Already typed first argument.
if ($prev_tokens | length) > 2 and $preceding ends-with ' ' {
return (get-checkoutable-files)
}
# The first argument can be local branches, remote branches, files and commits
# Get local and remote branches
let branches = (get-mergable-sources) | insert style {|row| if $row.description == 'Local' { 'blue' } else 'blue_italic' } | update description { $in + ' branch' }
let files = (get-checkoutable-files) | wrap value | insert description 'File' | insert style green
let commits = ^git rev-list -n 400 --remotes --oneline | lines | split column -n 2 ' ' value description | insert style light_cyan_dimmed
{
options: { options: {
case_sensitive: false, case_sensitive: false,
completion_algorithm: prefix, completion_algorithm: prefix,
sort: false, sort: false,
}, },
completions: $table_of_checkouts completions: [...$branches, ...$files, ...$commits]
} }
} }
@ -836,5 +961,14 @@ export extern "git grep" [
] ]
export extern "git" [ export extern "git" [
command?: string@"nu-complete git subcommands" # subcommands command?: string@"nu-complete git subcommands" # Subcommands
--version(-v) # Prints the Git suite version that the git program came from
--help(-h) # Prints the synopsis and a list of the most commonly used commands
--html-path # Print the path, without trailing slash, where Gits HTML documentation is installed and exit
--man-path # Print the manpath (see man(1)) for the man pages for this version of Git and exit
--info-path # Print the path where the Info files documenting this version of Git are installed and exit
--paginate(-p) # Pipe all output into less (or if set, $env.PAGER) if standard output is a terminal
--no-pager(-P) # Do not pipe Git output into a pager
--no-replace-objects # Do not use replacement refs to replace Git objects
--bare # Treat the repository as a bare repository
] ]