Skip to content

Commit 1e7fdd4

Browse files
authored
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)
1 parent d8a3d12 commit 1e7fdd4

File tree

1 file changed

+173
-39
lines changed

1 file changed

+173
-39
lines changed

custom-completions/git/git-completions.nu

+173-39
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,131 @@
1+
module git-completion-utils {
2+
export const GIT_SKIPABLE_FLAGS = ['-v', '--version', '-h', '--help', '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--bare']
3+
4+
# Helper function to append token if non-empty
5+
def append-non-empty [token: string]: list<string> -> list<string> {
6+
if ($token | is-empty) { $in } else { $in | append $token }
7+
}
8+
9+
# Split a string to list of args, taking quotes into account.
10+
# Code is copied and modified from https://github.com/nushell/nushell/issues/14582#issuecomment-2542596272
11+
export def args-split []: string -> list<string> {
12+
# Define our states
13+
const STATE_NORMAL = 0
14+
const STATE_IN_SINGLE_QUOTE = 1
15+
const STATE_IN_DOUBLE_QUOTE = 2
16+
const STATE_ESCAPE = 3
17+
const WHITESPACES = [" " "\t" "\n" "\r"]
18+
19+
# Initialize variables
20+
mut state = $STATE_NORMAL
21+
mut current_token = ""
22+
mut result: list<string> = []
23+
mut prev_state = $STATE_NORMAL
24+
25+
# Process each character
26+
for char in ($in | split chars) {
27+
if $state == $STATE_ESCAPE {
28+
# Handle escaped character
29+
$current_token = $current_token + $char
30+
$state = $prev_state
31+
} else if $char == '\' {
32+
# Enter escape state
33+
$prev_state = $state
34+
$state = $STATE_ESCAPE
35+
} else if $state == $STATE_NORMAL {
36+
if $char == "'" {
37+
$state = $STATE_IN_SINGLE_QUOTE
38+
} else if $char == '"' {
39+
$state = $STATE_IN_DOUBLE_QUOTE
40+
} else if ($char in $WHITESPACES) {
41+
# Whitespace in normal state means token boundary
42+
$result = $result | append-non-empty $current_token
43+
$current_token = ""
44+
} else {
45+
$current_token = $current_token + $char
46+
}
47+
} else if $state == $STATE_IN_SINGLE_QUOTE {
48+
if $char == "'" {
49+
$state = $STATE_NORMAL
50+
} else {
51+
$current_token = $current_token + $char
52+
}
53+
} else if $state == $STATE_IN_DOUBLE_QUOTE {
54+
if $char == '"' {
55+
$state = $STATE_NORMAL
56+
} else {
57+
$current_token = $current_token + $char
58+
}
59+
}
60+
}
61+
# Handle the last token
62+
$result = $result | append-non-empty $current_token
63+
# Return the result
64+
$result
65+
}
66+
67+
# Get changed files which can be restored by `git checkout --`
68+
export def get-changed-files []: nothing -> list<string> {
69+
^git status -uno --porcelain=2 | lines
70+
| where $it =~ '^1 [.MD]{2}'
71+
| each { split row ' ' -n 9 | last }
72+
}
73+
74+
# Get files which can be retrieved from a branch/commit by `git checkout <tree-ish>`
75+
export def get-checkoutable-files []: nothing -> list<string> {
76+
# Relevant statuses are .M", "MM", "MD", ".D", "UU"
77+
^git status -uno --porcelain=2 | lines
78+
| where $it =~ '^1 ([.MD]{2}|UU)'
79+
| each { split row ' ' -n 9 | last }
80+
}
81+
82+
export def get-all-git-branches []: nothing -> list<string> {
83+
^git branch -a --format '%(refname:lstrip=2)%09%(upstream:lstrip=2)' | lines | str trim | filter { not ($in ends-with 'HEAD' ) }
84+
}
85+
86+
# Extract remote branches which do not have local counterpart
87+
export def extract-remote-branches-nonlocal-short [current: string]: list<string> -> list<string> {
88+
# Input is a list of lines, like:
89+
# ╭────┬────────────────────────────────────────────────╮
90+
# │ 0 │ feature/awesome-1 origin/feature/awesome-1 │
91+
# │ 1 │ fix/bug-1 origin/fix/bug-1 │
92+
# │ 2 │ main origin/main │
93+
# │ 3 │ origin/HEAD │
94+
# │ 4 │ origin/feature/awesome-1 │
95+
# │ 5 │ origin/fix/bug-1 │
96+
# │ 6 │ origin/feature/awesome-2 │
97+
# │ 7 │ origin/main │
98+
# │ 8 │ upstream/main │
99+
# │ 9 │ upstream/awesome-3 │
100+
# ╰────┴────────────────────────────────────────────────╯
101+
# and we pick ['feature/awesome-2', 'awesome-3']
102+
let lines = $in
103+
let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' }
104+
let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") }
105+
let tracked_remotes = $branches | find --no-highlight "\t" | each { split row "\t" -n 2 | get 1 }
106+
let floating_remotes = $lines | filter { "\t" not-in $in and $in not-in $tracked_remotes }
107+
$floating_remotes | each {
108+
let v = $in | split row -n 2 '/' | get 1
109+
if $v != $current { [$v] } else []
110+
} | flatten
111+
}
112+
113+
export def extract-mergable-sources [current: string]: list<string> -> list<record<value: string, description: string>> {
114+
let lines = $in
115+
let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' }
116+
let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") }
117+
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 } }
118+
let siblings = $git_table | where u == null and n starts-with 'origin/' | get n | str substring 7..
119+
let remote_branches = $git_table | filter {|r| $r.u == null and not ($r.n starts-with 'origin/') } | get n
120+
[...($siblings | wrap value | insert description Local), ...($remote_branches | wrap value | insert description Remote)]
121+
}
122+
123+
# Get local branches, remote branches which can be passed to `git merge`
124+
export def get-mergable-sources []: nothing -> list<record<value: string, description: string>> {
125+
let current = (^git branch --show-current) # Can be empty if in detached HEAD
126+
(get-all-git-branches | extract-mergable-sources $current)
127+
}
128+
}
1129

2130
def "nu-complete git available upstream" [] {
3131
^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" [] {
32160
^git branch --no-color -r | lines | parse -r '^\*?(\s*|\s*\S* -> )(?P<branch>\S*$)' | get branch | uniq
33161
}
34162

35-
# Yield remote branches *without* prefix which do not have a local counterpart.
36-
# E.g. `upstream/feature-a` as `feature-a` to checkout and track in one command
37-
# with `git checkout` or `git switch`.
38-
def "nu-complete git remote branches nonlocal without prefix" [] {
39-
# Get regex to strip remotes prefixes. It will look like `(origin|upstream)`
40-
# for the two remotes `origin` and `upstream`.
41-
let remotes_regex = (["(", ((nu-complete git remotes | each {|r| [$r, '/'] | str join}) | str join "|"), ")"] | str join)
42-
let local_branches = (nu-complete git local branches)
43-
^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 }
44-
}
45-
46163
# Yield local and remote branch names which can be passed to `git merge`
47164
def "nu-complete git mergable sources" [] {
48-
let current = (^git branch --show-current)
49-
let long_current = $'origin/($current)'
50-
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 } }
51-
let siblings = $git_table | where u == null and n starts-with 'origin/' | get n | str substring 7..
52-
let remote_branches = $git_table | filter {|r| $r.u == null and not ($r.n starts-with 'origin/') } | get n
53-
[...($siblings | wrap value | insert description Local), ...($remote_branches | wrap value | insert description Remote)]
165+
use git-completion-utils *
166+
(get-mergable-sources)
54167
}
55168

56169
def "nu-complete git switch" [] {
57-
(nu-complete git local branches)
58-
| parse "{value}"
59-
| insert description "local branch"
60-
| append (nu-complete git remote branches nonlocal without prefix
61-
| parse "{value}"
62-
| insert description "remote branch")
170+
use git-completion-utils *
171+
let current = (^git branch --show-current) # Can be empty if in detached HEAD
172+
let local_branches = ^git branch --format '%(refname:short)' | lines | filter { $in != $current } | wrap value | insert description 'Local branch'
173+
let remote_branches = (get-all-git-branches | extract-remote-branches-nonlocal-short $current) | wrap value | insert description 'Remote branch'
174+
[...$local_branches, ...$remote_branches]
63175
}
64176

65-
def "nu-complete git checkout" [] {
66-
let table_of_checkouts = (nu-complete git local branches)
67-
| parse "{value}"
68-
| insert description "local branch"
69-
| append (nu-complete git remote branches nonlocal without prefix
70-
| parse "{value}"
71-
| insert description "remote branch")
72-
| append (nu-complete git remote branches with prefix
73-
| parse "{value}"
74-
| insert description "remote branch")
75-
| append (nu-complete git files | where description != "Untracked" | select value | insert description "git file")
76-
| append (nu-complete git commits all)
77-
78-
return {
177+
def "nu-complete git checkout" [context: string, position?:int] {
178+
use git-completion-utils *
179+
let preceding = $context | str substring ..$position
180+
# See what user typed before, like 'git checkout a-branch a-path'.
181+
# We exclude some flags from previous tokens, to detect if a branch name has been used as the first argument.
182+
# FIXME: This method is still naive, though.
183+
let prev_tokens = $preceding | str trim | args-split | where ($it not-in $GIT_SKIPABLE_FLAGS)
184+
# In these scenarios, we suggest only file paths, not branch:
185+
# - After '--'
186+
# - First arg is a branch
187+
# If before '--' is just 'git checkout' (or its alias), we suggest "dirty" files only (user is about to reset file).
188+
if $prev_tokens.2? == '--' {
189+
return (get-changed-files)
190+
}
191+
if '--' in $prev_tokens {
192+
return (get-checkoutable-files)
193+
}
194+
# Already typed first argument.
195+
if ($prev_tokens | length) > 2 and $preceding ends-with ' ' {
196+
return (get-checkoutable-files)
197+
}
198+
# The first argument can be local branches, remote branches, files and commits
199+
# Get local and remote branches
200+
let branches = (get-mergable-sources) | insert style {|row| if $row.description == 'Local' { 'blue' } else 'blue_italic' } | update description { $in + ' branch' }
201+
let files = (get-checkoutable-files) | wrap value | insert description 'File' | insert style green
202+
let commits = ^git rev-list -n 400 --remotes --oneline | lines | split column -n 2 ' ' value description | insert style light_cyan_dimmed
203+
{
79204
options: {
80205
case_sensitive: false,
81206
completion_algorithm: prefix,
82207
sort: false,
83208
},
84-
completions: $table_of_checkouts
209+
completions: [...$branches, ...$files, ...$commits]
85210
}
86211
}
87212

@@ -836,5 +961,14 @@ export extern "git grep" [
836961
]
837962

838963
export extern "git" [
839-
command?: string@"nu-complete git subcommands" # subcommands
964+
command?: string@"nu-complete git subcommands" # Subcommands
965+
--version(-v) # Prints the Git suite version that the git program came from
966+
--help(-h) # Prints the synopsis and a list of the most commonly used commands
967+
--html-path # Print the path, without trailing slash, where Git’s HTML documentation is installed and exit
968+
--man-path # Print the manpath (see man(1)) for the man pages for this version of Git and exit
969+
--info-path # Print the path where the Info files documenting this version of Git are installed and exit
970+
--paginate(-p) # Pipe all output into less (or if set, $env.PAGER) if standard output is a terminal
971+
--no-pager(-P) # Do not pipe Git output into a pager
972+
--no-replace-objects # Do not use replacement refs to replace Git objects
973+
--bare # Treat the repository as a bare repository
840974
]

0 commit comments

Comments
 (0)