|
| 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 | +} |
1 | 129 |
|
2 | 130 | def "nu-complete git available upstream" [] {
|
3 | 131 | ^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" [] {
|
32 | 160 | ^git branch --no-color -r | lines | parse -r '^\*?(\s*|\s*\S* -> )(?P<branch>\S*$)' | get branch | uniq
|
33 | 161 | }
|
34 | 162 |
|
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 |
| - |
46 | 163 | # Yield local and remote branch names which can be passed to `git merge`
|
47 | 164 | 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) |
54 | 167 | }
|
55 | 168 |
|
56 | 169 | 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] |
63 | 175 | }
|
64 | 176 |
|
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 | + { |
79 | 204 | options: {
|
80 | 205 | case_sensitive: false,
|
81 | 206 | completion_algorithm: prefix,
|
82 | 207 | sort: false,
|
83 | 208 | },
|
84 |
| - completions: $table_of_checkouts |
| 209 | + completions: [...$branches, ...$files, ...$commits] |
85 | 210 | }
|
86 | 211 | }
|
87 | 212 |
|
@@ -836,5 +961,14 @@ export extern "git grep" [
|
836 | 961 | ]
|
837 | 962 |
|
838 | 963 | 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 |
840 | 974 | ]
|
0 commit comments