|
| 1 | +# nu-version: 0.102.0 |
| 2 | + |
| 3 | +module git-completion-utils { |
| 4 | + export const GIT_SKIPABLE_FLAGS = ['-v', '--version', '-h', '--help', '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--bare'] |
| 5 | + |
| 6 | + # Helper function to append token if non-empty |
| 7 | + def append-non-empty [token: string]: list<string> -> list<string> { |
| 8 | + if ($token | is-empty) { $in } else { $in | append $token } |
| 9 | + } |
| 10 | + |
| 11 | + # Split a string to list of args, taking quotes into account. |
| 12 | + # Code is copied and modified from https://github.com/nushell/nushell/issues/14582#issuecomment-2542596272 |
| 13 | + export def args-split []: string -> list<string> { |
| 14 | + # Define our states |
| 15 | + const STATE_NORMAL = 0 |
| 16 | + const STATE_IN_SINGLE_QUOTE = 1 |
| 17 | + const STATE_IN_DOUBLE_QUOTE = 2 |
| 18 | + const STATE_ESCAPE = 3 |
| 19 | + const WHITESPACES = [" " "\t" "\n" "\r"] |
| 20 | + |
| 21 | + # Initialize variables |
| 22 | + mut state = $STATE_NORMAL |
| 23 | + mut current_token = "" |
| 24 | + mut result: list<string> = [] |
| 25 | + mut prev_state = $STATE_NORMAL |
| 26 | + |
| 27 | + # Process each character |
| 28 | + for char in ($in | split chars) { |
| 29 | + if $state == $STATE_ESCAPE { |
| 30 | + # Handle escaped character |
| 31 | + $current_token = $current_token + $char |
| 32 | + $state = $prev_state |
| 33 | + } else if $char == '\' { |
| 34 | + # Enter escape state |
| 35 | + $prev_state = $state |
| 36 | + $state = $STATE_ESCAPE |
| 37 | + } else if $state == $STATE_NORMAL { |
| 38 | + if $char == "'" { |
| 39 | + $state = $STATE_IN_SINGLE_QUOTE |
| 40 | + } else if $char == '"' { |
| 41 | + $state = $STATE_IN_DOUBLE_QUOTE |
| 42 | + } else if ($char in $WHITESPACES) { |
| 43 | + # Whitespace in normal state means token boundary |
| 44 | + $result = $result | append-non-empty $current_token |
| 45 | + $current_token = "" |
| 46 | + } else { |
| 47 | + $current_token = $current_token + $char |
| 48 | + } |
| 49 | + } else if $state == $STATE_IN_SINGLE_QUOTE { |
| 50 | + if $char == "'" { |
| 51 | + $state = $STATE_NORMAL |
| 52 | + } else { |
| 53 | + $current_token = $current_token + $char |
| 54 | + } |
| 55 | + } else if $state == $STATE_IN_DOUBLE_QUOTE { |
| 56 | + if $char == '"' { |
| 57 | + $state = $STATE_NORMAL |
| 58 | + } else { |
| 59 | + $current_token = $current_token + $char |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + # Handle the last token |
| 64 | + $result = $result | append-non-empty $current_token |
| 65 | + # Return the result |
| 66 | + $result |
| 67 | + } |
| 68 | + |
| 69 | + # Get changed files which can be restored by `git checkout --` |
| 70 | + export def get-changed-files []: nothing -> list<string> { |
| 71 | + ^git status -uno --porcelain=2 | lines |
| 72 | + | where $it =~ '^1 [.MD]{2}' |
| 73 | + | each { split row ' ' -n 9 | last } |
| 74 | + } |
| 75 | + |
| 76 | + # Get files which can be retrieved from a branch/commit by `git checkout <tree-ish>` |
| 77 | + export def get-checkoutable-files []: nothing -> list<string> { |
| 78 | + # Relevant statuses are .M", "MM", "MD", ".D", "UU" |
| 79 | + ^git status -uno --porcelain=2 | lines |
| 80 | + | where $it =~ '^1 ([.MD]{2}|UU)' |
| 81 | + | each { split row ' ' -n 9 | last } |
| 82 | + } |
| 83 | + |
| 84 | + export def get-all-git-branches []: nothing -> list<string> { |
| 85 | + ^git branch -a --format '%(refname:lstrip=2)%09%(upstream:lstrip=2)' | lines | str trim | filter { not ($in ends-with 'HEAD' ) } |
| 86 | + } |
| 87 | + |
| 88 | + # Extract remote branches which do not have local counterpart |
| 89 | + export def extract-remote-branches-nonlocal-short [current: string]: list<string> -> list<string> { |
| 90 | + # Input is a list of lines, like: |
| 91 | + # ╭────┬────────────────────────────────────────────────╮ |
| 92 | + # │ 0 │ feature/awesome-1 origin/feature/awesome-1 │ |
| 93 | + # │ 1 │ fix/bug-1 origin/fix/bug-1 │ |
| 94 | + # │ 2 │ main origin/main │ |
| 95 | + # │ 3 │ origin/HEAD │ |
| 96 | + # │ 4 │ origin/feature/awesome-1 │ |
| 97 | + # │ 5 │ origin/fix/bug-1 │ |
| 98 | + # │ 6 │ origin/feature/awesome-2 │ |
| 99 | + # │ 7 │ origin/main │ |
| 100 | + # │ 8 │ upstream/main │ |
| 101 | + # │ 9 │ upstream/awesome-3 │ |
| 102 | + # ╰────┴────────────────────────────────────────────────╯ |
| 103 | + # and we pick ['feature/awesome-2', 'awesome-3'] |
| 104 | + let lines = $in |
| 105 | + let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' } |
| 106 | + let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") } |
| 107 | + let tracked_remotes = $branches | find --no-highlight "\t" | each { split row "\t" -n 2 | get 1 } |
| 108 | + let floating_remotes = $lines | filter { "\t" not-in $in and $in not-in $tracked_remotes } |
| 109 | + $floating_remotes | each { |
| 110 | + let v = $in | split row -n 2 '/' | get 1 |
| 111 | + if $v == $current { null } else $v |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + export def extract-mergable-sources [current: string]: list<string> -> list<record<value: string, description: string>> { |
| 116 | + let lines = $in |
| 117 | + let long_current = if ($current | is-empty) { '' } else { $'origin/($current)' } |
| 118 | + let branches = $lines | filter { ($in != $long_current) and not ($in starts-with $"($current)\t") } |
| 119 | + 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 } } |
| 120 | + let siblings = $git_table | where u == null and n starts-with 'origin/' | get n | str substring 7.. |
| 121 | + let remote_branches = $git_table | filter {|r| $r.u == null and not ($r.n starts-with 'origin/') } | get n |
| 122 | + [...($siblings | wrap value | insert description Local), ...($remote_branches | wrap value | insert description Remote)] |
| 123 | + } |
| 124 | + |
| 125 | + # Get local branches, remote branches which can be passed to `git merge` |
| 126 | + export def get-mergable-sources []: nothing -> list<record<value: string, description: string>> { |
| 127 | + let current = (^git branch --show-current) # Can be empty if in detached HEAD |
| 128 | + (get-all-git-branches | extract-mergable-sources $current) |
| 129 | + } |
| 130 | +} |
1 | 131 |
|
2 | 132 | def "nu-complete git available upstream" [] { |
3 | 133 | ^git branch --no-color -a | lines | each { |line| $line | str replace '* ' "" | str trim } |
@@ -32,56 +162,53 @@ def "nu-complete git remote branches with prefix" [] { |
32 | 162 | ^git branch --no-color -r | lines | parse -r '^\*?(\s*|\s*\S* -> )(?P<branch>\S*$)' | get branch | uniq |
33 | 163 | } |
34 | 164 |
|
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 | 165 | # Yield local and remote branch names which can be passed to `git merge` |
47 | 166 | 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)] |
| 167 | + use git-completion-utils * |
| 168 | + (get-mergable-sources) |
54 | 169 | } |
55 | 170 |
|
56 | 171 | 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") |
| 172 | + use git-completion-utils * |
| 173 | + let current = (^git branch --show-current) # Can be empty if in detached HEAD |
| 174 | + let local_branches = ^git branch --format '%(refname:short)' | lines | filter { $in != $current } | wrap value | insert description 'Local branch' |
| 175 | + let remote_branches = (get-all-git-branches | extract-remote-branches-nonlocal-short $current) | wrap value | insert description 'Remote branch' |
| 176 | + [...$local_branches, ...$remote_branches] |
63 | 177 | } |
64 | 178 |
|
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 { |
| 179 | +def "nu-complete git checkout" [context: string, position?:int] { |
| 180 | + use git-completion-utils * |
| 181 | + let preceding = $context | str substring ..$position |
| 182 | + # See what user typed before, like 'git checkout a-branch a-path'. |
| 183 | + # We exclude some flags from previous tokens, to detect if a branch name has been used as the first argument. |
| 184 | + # FIXME: This method is still naive, though. |
| 185 | + let prev_tokens = $preceding | str trim | args-split | where ($it not-in $GIT_SKIPABLE_FLAGS) |
| 186 | + # In these scenarios, we suggest only file paths, not branch: |
| 187 | + # - After '--' |
| 188 | + # - First arg is a branch |
| 189 | + # If before '--' is just 'git checkout' (or its alias), we suggest "dirty" files only (user is about to reset file). |
| 190 | + if $prev_tokens.2? == '--' { |
| 191 | + return (get-changed-files) |
| 192 | + } |
| 193 | + if '--' in $prev_tokens { |
| 194 | + return (get-checkoutable-files) |
| 195 | + } |
| 196 | + # Already typed first argument. |
| 197 | + if ($prev_tokens | length) > 2 and $preceding ends-with ' ' { |
| 198 | + return (get-checkoutable-files) |
| 199 | + } |
| 200 | + # The first argument can be local branches, remote branches, files and commits |
| 201 | + # Get local and remote branches |
| 202 | + let branches = (get-mergable-sources) | insert style {|row| if $row.description == 'Local' { 'blue' } else 'blue_italic' } | update description { $in + ' branch' } |
| 203 | + let files = (get-checkoutable-files) | wrap value | insert description 'File' | insert style green |
| 204 | + let commits = ^git rev-list -n 400 --remotes --oneline | lines | split column -n 2 ' ' value description | insert style light_cyan_dimmed |
| 205 | + { |
79 | 206 | options: { |
80 | 207 | case_sensitive: false, |
81 | 208 | completion_algorithm: prefix, |
82 | 209 | sort: false, |
83 | 210 | }, |
84 | | - completions: $table_of_checkouts |
| 211 | + completions: [...$branches, ...$files, ...$commits] |
85 | 212 | } |
86 | 213 | } |
87 | 214 |
|
@@ -143,7 +270,7 @@ def "nu-complete git files" [] { |
143 | 270 | def "nu-complete git built-in-refs" [] { |
144 | 271 | [HEAD FETCH_HEAD ORIG_HEAD] |
145 | 272 | } |
146 | | - |
| 273 | + |
147 | 274 | def "nu-complete git refs" [] { |
148 | 275 | nu-complete git local branches |
149 | 276 | | parse "{value}" |
@@ -836,5 +963,14 @@ export extern "git grep" [ |
836 | 963 | ] |
837 | 964 |
|
838 | 965 | export extern "git" [ |
839 | | - command?: string@"nu-complete git subcommands" # subcommands |
| 966 | + command?: string@"nu-complete git subcommands" # Subcommands |
| 967 | + --version(-v) # Prints the Git suite version that the git program came from |
| 968 | + --help(-h) # Prints the synopsis and a list of the most commonly used commands |
| 969 | + --html-path # Print the path, without trailing slash, where Git’s HTML documentation is installed and exit |
| 970 | + --man-path # Print the manpath (see man(1)) for the man pages for this version of Git and exit |
| 971 | + --info-path # Print the path where the Info files documenting this version of Git are installed and exit |
| 972 | + --paginate(-p) # Pipe all output into less (or if set, $env.PAGER) if standard output is a terminal |
| 973 | + --no-pager(-P) # Do not pipe Git output into a pager |
| 974 | + --no-replace-objects # Do not use replacement refs to replace Git objects |
| 975 | + --bare # Treat the repository as a bare repository |
840 | 976 | ] |
0 commit comments