├── LICENSE ├── README.md ├── doc └── mini-git.txt └── lua └── mini └── git.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Evgeni Chasnovski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

mini.git

2 | 3 | ### Git integration 4 | 5 | See more details in [Features](#features) and [Documentation](doc/mini-git.txt). 6 | 7 | --- 8 | 9 | > [!NOTE] 10 | > This was previously hosted at a personal `echasnovski` GitHub account. It was transferred to a dedicated organization to improve long term project stability. See more details [here](https://github.com/nvim-mini/mini.nvim/discussions/1970). 11 | 12 | ⦿ This is a part of [mini.nvim](https://nvim-mini.org/mini.nvim) library. Please use [this link](https://nvim-mini.org/mini.nvim/readmes/mini-git) if you want to mention this module. 13 | 14 | ⦿ All contributions (issues, pull requests, discussions, etc.) are done inside of 'mini.nvim'. 15 | 16 | ⦿ See [whole library documentation](https://nvim-mini.org/mini.nvim/doc/mini-nvim) to learn about general design principles, disable/configuration recipes, and more. 17 | 18 | ⦿ See [MiniMax](https://nvim-mini.org/MiniMax) for a full config example that uses this module. 19 | 20 | --- 21 | 22 | If you want to help this project grow but don't know where to start, check out [contributing guides of 'mini.nvim'](https://nvim-mini.org/mini.nvim/CONTRIBUTING) or leave a Github star for 'mini.nvim' project and/or any its standalone Git repositories. 23 | 24 | ## Demo 25 | 26 | 27 | https://github.com/nvim-mini/mini.nvim/assets/24854248/3c2b34cd-f04f-4e30-9ca4-1ff51e2d65a2 28 | 29 | **Note**: This demo uses custom `vim.notify()` from [mini.notify](https://nvim-mini.org/mini.nvim/readmes/mini-notify) and diff line number highlighting from [mini.diff](https://nvim-mini.org/mini.nvim/readmes/mini-diff). 30 | 31 | ## Features 32 | 33 | - Automated tracking of [Git](https://git-scm.com/) related data: root path, status, HEAD, etc. Exposes buffer-local variables for convenient use in statusline. 34 | 35 | - `:Git` command for executing any `git` call inside file's repository root with deeper current instance integration (show output as notification/buffer, use to edit commit messages, etc.). 36 | 37 | - Helper functions to inspect Git history: 38 | - `MiniGit.show_range_history()` shows how certain line range evolved. 39 | - `MiniGit.show_diff_source()` shows file state as it was at diff entry. 40 | - `MiniGit.show_at_cursor()` shows Git related data depending on context. 41 | 42 | What it doesn't do: 43 | 44 | - Replace fully featured Git client. Rule of thumb: if feature does not rely on a state of current Neovim (opened buffers, etc.), it is out of scope. For more functionality, use either ['mini.diff'](https://nvim-mini.org/mini.nvim/readmes/mini-diff) or fully featured Git client. 45 | 46 | For more information see these parts of help: 47 | 48 | - `:h :Git` 49 | - `:h MiniGit-examples` 50 | - `:h MiniGit.enable()` 51 | - `:h MiniGit.get_buf_data()` 52 | 53 | ## Installation 54 | 55 | This plugin can be installed as part of 'mini.nvim' library (**recommended**) or as a standalone Git repository. 56 | 57 | There are two branches to install from: 58 | 59 | - `main` (default, **recommended**) will have latest development version of plugin. All changes since last stable release should be perceived as being in beta testing phase (meaning they already passed alpha-testing and are moderately settled). 60 | - `stable` will be updated only upon releases with code tested during public beta-testing phase in `main` branch. 61 | 62 | Here are code snippets for some common installation methods (use only one): 63 | 64 |
65 | With mini.deps 66 | 67 | - 'mini.nvim' library: 68 | 69 | | Branch | Code snippet | 70 | |--------|-----------------------------------------------| 71 | | Main | *Follow recommended ‘mini.deps’ installation* | 72 | | Stable | *Follow recommended ‘mini.deps’ installation* | 73 | 74 | - Standalone plugin: 75 | 76 | | Branch | Code snippet | 77 | |--------|---------------------------------------------------------------| 78 | | Main | `add(‘nvim-mini/mini-git’)` | 79 | | Stable | `add({ source = ‘nvim-mini/mini-git’, checkout = ‘stable’ })` | 80 | 81 |
82 | 83 |
84 | With folke/lazy.nvim 85 | 86 | - 'mini.nvim' library: 87 | 88 | | Branch | Code snippet | 89 | |--------|-----------------------------------------------| 90 | | Main | `{ 'nvim-mini/mini.nvim', version = false },` | 91 | | Stable | `{ 'nvim-mini/mini.nvim', version = '*' },` | 92 | 93 | - Standalone plugin: 94 | 95 | | Branch | Code snippet | 96 | |--------|----------------------------------------------| 97 | | Main | `{ 'nvim-mini/mini-git', version = false },` | 98 | | Stable | `{ 'nvim-mini/mini-git', version = '*' },` | 99 | 100 |
101 | 102 |
103 | With junegunn/vim-plug 104 | 105 | - 'mini.nvim' library: 106 | 107 | | Branch | Code snippet | 108 | |--------|------------------------------------------------------| 109 | | Main | `Plug 'nvim-mini/mini.nvim'` | 110 | | Stable | `Plug 'nvim-mini/mini.nvim', { 'branch': 'stable' }` | 111 | 112 | - Standalone plugin: 113 | 114 | | Branch | Code snippet | 115 | |--------|-----------------------------------------------------| 116 | | Main | `Plug 'nvim-mini/mini-git'` | 117 | | Stable | `Plug 'nvim-mini/mini-git', { 'branch': 'stable' }` | 118 | 119 |
120 | 121 | **Important**: don't forget to call `require('mini.git').setup()` to enable its functionality. 122 | 123 | **Note**: if you are on Windows, there might be problems with too long file paths (like `error: unable to create file : Filename too long`). Try doing one of the following: 124 | 125 | - Enable corresponding git global config value: `git config --system core.longpaths true`. Then try to reinstall. 126 | - Install plugin in other place with shorter path. 127 | 128 | ## Default config 129 | 130 | ```lua 131 | -- No need to copy this inside `setup()`. Will be used automatically. 132 | { 133 | -- General CLI execution 134 | job = { 135 | -- Path to Git executable 136 | git_executable = 'git', 137 | 138 | -- Timeout (in ms) for each job before force quit 139 | timeout = 30000, 140 | }, 141 | 142 | -- Options for `:Git` command 143 | command = { 144 | -- Default split direction 145 | split = 'auto', 146 | }, 147 | } 148 | ``` 149 | 150 | ## Similar plugins 151 | 152 | - [tpope/vim-fugitive](https://github.com/tpope/vim-fugitive) 153 | - [NeogitOrg/neogit](https://github.com/NeogitOrg/neogit) 154 | - [lewis6991/gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim) 155 | -------------------------------------------------------------------------------- /doc/mini-git.txt: -------------------------------------------------------------------------------- 1 | *mini.git* Git integration 2 | 3 | MIT License Copyright (c) 2024 Evgeni Chasnovski 4 | 5 | ------------------------------------------------------------------------------ 6 | *MiniGit* 7 | Features: 8 | 9 | - Automated tracking of Git related data: root path, status, HEAD, etc. 10 | Exposes buffer-local variables for convenient use in statusline. 11 | See |MiniGit.enable()| and |MiniGit.get_buf_data()| for more information. 12 | 13 | - |:Git| command for executing any `git` call inside file's repository root with 14 | deeper current instance integration (show output as notification/buffer, 15 | use to edit commit messages, etc.). 16 | 17 | - Helper functions to inspect Git history: 18 | - |MiniGit.show_range_history()| shows how certain line range evolved. 19 | - |MiniGit.show_diff_source()| shows file state as it was at diff entry. 20 | - |MiniGit.show_at_cursor()| shows Git related data depending on context. 21 | 22 | What it doesn't do: 23 | 24 | - Replace fully featured Git client. Rule of thumb: if feature does not rely 25 | on a state of current Neovim (opened buffers, etc.), it is out of scope. 26 | For more functionality, use either |mini.diff| or fully featured Git client. 27 | 28 | Sources with more details: 29 | - |:Git| 30 | - |MiniGit-examples| 31 | - |MiniGit.enable()| 32 | - |MiniGit.get_buf_data()| 33 | 34 | # Setup ~ 35 | 36 | This module needs a setup with `require('mini.git').setup({})` (replace `{}` with 37 | your `config` table). It will create global Lua table `MiniGit` which you can use 38 | for scripting or manually (with `:lua MiniGit.*`). 39 | 40 | See |MiniGit.config| for `config` structure and default values. 41 | 42 | # Comparisons ~ 43 | 44 | - [tpope/vim-fugitive](https://github.com/tpope/vim-fugitive): 45 | - Mostly a dedicated Git client, while this module is not (by design). 46 | - Provides buffer-local Git data only through fixed statusline component, 47 | while this module has richer data in the form of a Lua table. 48 | - Both provide |:Git| command with 'vim-fugitive' treating some cases 49 | extra specially (like `:Git blame`, etc.), while this module mostly 50 | treats all cases the same. See |MiniGit-examples| for how they can be 51 | manually customized. 52 | Also this module provides slightly different (usually richer) 53 | completion suggestions. 54 | 55 | - [NeogitOrg/neogit](https://github.com/NeogitOrg/neogit): 56 | - Similar to 'tpope/vim-fugitive', but without `:Git` command. 57 | 58 | - [lewis6991/gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim): 59 | - Provides buffer-local Git data with emphasis on granular diff status, 60 | while this module is more oriented towards repository and file level 61 | data (root, HEAD, file status, etc.). Use |mini.diff| for diff tracking. 62 | 63 | # Disabling ~ 64 | 65 | To prevent buffer(s) from being tracked, set `vim.g.minigit_disable` (globally) 66 | or `vim.b.minigit_disable` (for a buffer) to `true`. Considering high number of 67 | different scenarios and customization intentions, writing exact rules for 68 | disabling module's functionality is left to user. 69 | See |mini.nvim-disabling-recipes| for common recipes. 70 | 71 | ------------------------------------------------------------------------------ 72 | *MiniGit-examples* 73 | # Statusline component ~ 74 | 75 | Tracked buffer data can be used in statusline via `vim.b.minigit_summary_string` 76 | buffer-local variable. It is expected to be used as is. To show another info, 77 | tweak buffer-local variable directly inside `MiniGitUpdated` `User` event: >lua 78 | 79 | -- Use only HEAD name as summary string 80 | local format_summary = function(data) 81 | -- Utilize buffer-local table summary 82 | local summary = vim.b[data.buf].minigit_summary 83 | vim.b[data.buf].minigit_summary_string = summary.head_name or '' 84 | end 85 | 86 | local au_opts = { pattern = 'MiniGitUpdated', callback = format_summary } 87 | vim.api.nvim_create_autocmd('User', au_opts) 88 | < 89 | # Tweaking command output ~ 90 | 91 | Buffer output of |:Git| command can be tweaked inside autocommand for 92 | `MiniGitCommandSplit` `User` event (see |MiniGit-command-events|). 93 | For example, to make `:vertical Git blame -- %` align blame output with the 94 | current window state, use the following code: >lua 95 | 96 | local align_blame = function(au_data) 97 | if au_data.data.git_subcommand ~= 'blame' then return end 98 | 99 | -- Align blame output with source 100 | local win_src = au_data.data.win_source 101 | vim.wo.wrap = false 102 | vim.fn.winrestview({ topline = vim.fn.line('w0', win_src) }) 103 | vim.api.nvim_win_set_cursor(0, { vim.fn.line('.', win_src), 0 }) 104 | 105 | -- Bind both windows so that they scroll together 106 | vim.wo[win_src].scrollbind, vim.wo.scrollbind = true, true 107 | end 108 | 109 | local au_opts = { pattern = 'MiniGitCommandSplit', callback = align_blame } 110 | vim.api.nvim_create_autocmd('User', au_opts) 111 | < 112 | # History navigation ~ 113 | 114 | Function |MiniGit.show_at_cursor()| is specifically exported to make Git 115 | history navigation easier. Here are some different ways it can be used: 116 | 117 | - Call inside buffer for already committed file to show the evolution of 118 | the current line (or visually selected range) through history. 119 | It is essentially a `:Git log HEAD` with proper `-L` flag. 120 | This also works inside output of |MiniGit.show_diff_source()|. 121 | 122 | - Call with cursor on commit hash to inspect that commit in full. 123 | This is usually helpful in the output of `:Git log`. 124 | 125 | - Call with cursor inside diff entry to inspect its file in the state how it 126 | was at certain commit. By default it shows state after commit, unless cursor 127 | is on the "deleted" line (i.e. line starting with "-") in which case 128 | state before commit is shown. 129 | 130 | This workflow can be made more interactive when used with mapping, like this: >lua 131 | 132 | local rhs = 'lua MiniGit.show_at_cursor()' 133 | vim.keymap.set({ 'n', 'x' }, 'gs', rhs, { desc = 'Show at cursor' }) 134 | < 135 | ------------------------------------------------------------------------------ 136 | *:Git* 137 | The `:Git` user command runs `git` CLI call with extra integration for currently 138 | opened Neovim process: 139 | - Command is executed inside repository root of the currently active file 140 | (or |current-directory| if file is not tracked by this module). 141 | 142 | - Command output is shown either in dedicated buffer in window split or as 143 | notification via |vim.notify()|. Which method is used depends on whether 144 | particular Git subcommand is supposed to show data for user to inspect 145 | (like `log`, `status`, etc.) or not (like `commit`, `push`, etc.). This is 146 | determined automatically based on the data Git itself provides. 147 | Split window is made current after command execution. 148 | 149 | Use split-related |:command-modifiers| (|:vertical|, |:horizontal|, or |:tab|) 150 | to force output in a particular type of split. Default split direction is 151 | controlled by `command.split` in |MiniGit.config|. 152 | 153 | Use |:silent| command modifier to not show any output. 154 | 155 | Errors and warnings are always shown as notifications. 156 | 157 | See |MiniGit-examples| for the example of tweaking command output. 158 | 159 | - Editor for tasks that require interactive user input (like `:Git commit` or 160 | `:Git rebase --interactive`) is opened inside current session in a separate 161 | split. Make modifications as in regular buffer, |:write| changes followed by 162 | |:close| / |:quit| for Git CLI command to resume. 163 | 164 | Examples of usage: 165 | - `:Git log --oneline` - show compact log of current repository. 166 | - `:vert Git blame -- %` - show latest commits per line in vertical split. 167 | - `:Git help rebase` - show help page for `rebase` subcommand. 168 | - `:Git -C status` - execute `git status` inside |current-directory|. 169 | 170 | There is also a context aware completion which can be invoked with ``: 171 | - If completed word starts with "-", options for the current Git subcommand 172 | are shown. Like completion at `:Git log -` will suggest `-L`, `--oneline`, etc. 173 | - If there is an explicit " -- " to the cursor's left, incremental path 174 | suggestions will be shown. 175 | - If there is no recognized Git subcommand yet, show list of subcommands. 176 | Otherwise for some common subcommands list of its targets will be suggested: 177 | like for `:Git branch` it will be list of branches, etc. 178 | 179 | Notes: 180 | - Paths are always treated as relative to command's execution directory 181 | (file's repository root or |current-directory| if absent). 182 | - Don't use quotes for entries containing space, escape it with `\` directly. 183 | Like `:Git commit -m Hello\ world` and not `:Git commit -m 'Hello world'` 184 | (which treats `'Hello` and `world'` as separate arguments). 185 | 186 | # Events ~ 187 | *MiniGit-command-events* 188 | 189 | There are several `User` events triggered during command execution: 190 | 191 | - `MiniGitCommandDone` - after command is done executing. For Lua callbacks it 192 | provides a special `data` table with the following fields: 193 | - `(table)` - structured data about executed command. 194 | Has same structure as Lua function input in |nvim_create_user_command()|. 195 | - `(string)` - directory path inside which Git command was executed. 196 | - `(number)` - exit code of CLI process. 197 | - `(table)` - array with arguments of full executed command. 198 | - `(string)` - detected Git subcommand (like "log", etc.). 199 | - `(string)` - `stderr` process output. 200 | - `(string)` - `stdout` process output. 201 | 202 | - `MiniGitCommandSplit` - after command showed its output in a split. Triggered 203 | after `MiniGitCommandDone` and provides similar `data` table with extra fields: 204 | - `(number)` - window identifier of "source" window (current at 205 | the moment before command execution). 206 | - `(number)` - window identifier of command output. 207 | 208 | ------------------------------------------------------------------------------ 209 | *MiniGit.setup()* 210 | `MiniGit.setup`({config}) 211 | Module setup 212 | 213 | Besides general side effects (see |mini.nvim|), it also: 214 | - Sets up auto enabling in every normal buffer for an actual file on disk. 215 | - Creates |:Git| command. 216 | 217 | Parameters ~ 218 | {config} `(table|nil)` Module config table. See |MiniGit.config|. 219 | 220 | Usage ~ 221 | >lua 222 | require('mini.git').setup() -- use default config 223 | -- OR 224 | require('mini.git').setup({}) -- replace {} with your config table 225 | < 226 | ------------------------------------------------------------------------------ 227 | *MiniGit.config* 228 | `MiniGit.config` 229 | Defaults ~ 230 | >lua 231 | MiniGit.config = { 232 | -- General CLI execution 233 | job = { 234 | -- Path to Git executable 235 | git_executable = 'git', 236 | 237 | -- Timeout (in ms) for each job before force quit 238 | timeout = 30000, 239 | }, 240 | 241 | -- Options for `:Git` command 242 | command = { 243 | -- Default split direction 244 | split = 'auto', 245 | }, 246 | } 247 | < 248 | # Job ~ 249 | 250 | `config.job` contains options for customizing CLI executions. 251 | 252 | `job.git_executable` defines a full path to Git executable. Default: "git". 253 | 254 | `job.timeout` is a duration (in ms) from job start until it is forced to stop. 255 | Default: 30000. 256 | 257 | # Command ~ 258 | 259 | `config.command` contains options for customizing |:Git| command. 260 | 261 | `command.split` defines default split direction for |:Git| command output. Can be 262 | one of "horizontal", "vertical", "tab", or "auto". Value "auto" uses |:vertical| 263 | if only 'mini.git' buffers are shown in the tabpage and |:tab| otherwise. 264 | Default: "auto". 265 | 266 | ------------------------------------------------------------------------------ 267 | *MiniGit.show_at_cursor()* 268 | `MiniGit.show_at_cursor`({opts}) 269 | Show Git related data at cursor 270 | 271 | - If inside |mini.deps| confirmation buffer, show in split relevant commit data. 272 | - If there is a commit-like ||, show it in split. 273 | - If possible, show diff source via |MiniGit.show_diff_source()|. 274 | - If possible, show range history via |MiniGit.show_range_history()|. 275 | - Otherwise throw an error. 276 | 277 | Parameters ~ 278 | {opts} `(table|nil)` Options. Possible values: 279 | - `(string)` - split direction. One of "horizontal", "vertical", 280 | "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git' 281 | buffers are shown in the tabpage and |:tab| otherwise. 282 | - Fields appropriate for forwarding to other functions. 283 | 284 | ------------------------------------------------------------------------------ 285 | *MiniGit.show_diff_source()* 286 | `MiniGit.show_diff_source`({opts}) 287 | Show diff source 288 | 289 | When buffer contains text formatted as unified patch (like after 290 | `:Git log --patch`, `:Git diff`, or |MiniGit.show_range_history()|), 291 | show state of the file at the particular state. Target commit/state, path, 292 | and line number are deduced from cursor position. 293 | 294 | Notes: 295 | - Needs |current-directory| to be the Git root for relative paths to work. 296 | - Needs cursor to be inside hunk lines or on "---" / "+++" lines with paths. 297 | - Only basic forms of `:Git diff` output is supported: `:Git diff`, 298 | `:Git diff --cached`, and `:Git diff `. 299 | 300 | Parameters ~ 301 | {opts} `(table|nil)` Options. Possible values: 302 | - `(string)` - split direction. One of "horizontal", "vertical", 303 | "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git' 304 | buffers are shown in the tabpage and |:tab| otherwise. 305 | - `(string)` - which file state to show. One of "before", "after", 306 | "both" (both states in vertical split), "auto" (default). Value "auto" 307 | shows "before" state if cursor line starts with "-", otherwise - "after". 308 | 309 | ------------------------------------------------------------------------------ 310 | *MiniGit.show_range_history()* 311 | `MiniGit.show_range_history`({opts}) 312 | Show range history 313 | 314 | Compute and show in split data about how particular line range in current 315 | buffer evolved through Git history. Essentially a `git log` with `-L` flag. 316 | 317 | Notes: 318 | - Works well with |MiniGit.diff_foldexpr()|. 319 | - Does not work if there are uncommited changes, as there is no easy way to 320 | compute effective range line numbers. 321 | 322 | Parameters ~ 323 | {opts} `(table|nil)` Options. Possible fields: 324 | - `(number)` - range start line. 325 | - `(number)` - range end line. 326 | If both and are not supplied, they default to 327 | current line in Normal mode and visual selection in Visual mode. 328 | - `(table)` - array of options to append to `git log` call. 329 | - `(string)` - split direction. One of "horizontal", "vertical", 330 | "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git' 331 | buffers are shown in the tabpage and |:tab| otherwise. 332 | 333 | ------------------------------------------------------------------------------ 334 | *MiniGit.diff_foldexpr()* 335 | `MiniGit.diff_foldexpr`({lnum}) 336 | Fold expression for Git logs 337 | 338 | Folds contents of hunks, file patches, and log entries in unified diff. 339 | Useful for filetypes "diff" (like after `:Git diff`) and "git" (like after 340 | `:Git log --patch` or `:Git show` for commit). 341 | Works well with |MiniGit.show_range_history()|. 342 | 343 | General idea of folding levels (use |zr| and |zm| to adjust interactively): 344 | - At level 0 there is one line per whole patch or log entry. 345 | - At level 1 there is one line per patched file. 346 | - At level 2 there is one line per hunk. 347 | - At level 3 there is no folds. 348 | 349 | For automated setup, set the following for "git" and "diff" filetypes (either 350 | inside |FileType| autocommand or |ftplugin|): >vim 351 | 352 | setlocal foldmethod=expr foldexpr=v:lua.MiniGit.diff_foldexpr() 353 | < 354 | Parameters ~ 355 | {lnum} `(number|nil)` Line number for which fold level is computed. 356 | Default: |v:lnum|. 357 | 358 | Return ~ 359 | `(number|string)` Line fold level. See |fold-expr|. 360 | 361 | ------------------------------------------------------------------------------ 362 | *MiniGit.enable()* 363 | `MiniGit.enable`({buf_id}) 364 | Enable Git tracking in a file buffer 365 | 366 | Tracking is done by reacting to changes in file content or file's repository 367 | in the form of keeping buffer data up to date. The data can be used via: 368 | - |MiniGit.get_buf_data()|. See its help for a list of actually tracked data. 369 | - `vim.b.minigit_summary` (table) and `vim.b.minigit_summary_string` (string) 370 | buffer-local variables which are more suitable for statusline. 371 | `vim.b.minigit_summary_string` contains information about HEAD, file status, 372 | and in progress action (see |MiniGit.get_buf_data()| for more details). 373 | See |MiniGit-examples| for how it can be tweaked and used in statusline. 374 | 375 | Note: this function is called automatically for all new normal buffers. 376 | Use it explicitly if buffer was disabled. 377 | 378 | `User` event `MiniGitUpdated` is triggered whenever tracking data is updated. 379 | Note that not all data listed in |MiniGit.get_buf_data()| can be present (yet) 380 | at the point of event being triggered. 381 | 382 | Parameters ~ 383 | {buf_id} `(number)` Target buffer identifier. Default: 0 for current buffer. 384 | 385 | ------------------------------------------------------------------------------ 386 | *MiniGit.disable()* 387 | `MiniGit.disable`({buf_id}) 388 | Disable Git tracking in buffer 389 | 390 | Parameters ~ 391 | {buf_id} `(number)` Target buffer identifier. Default: 0 for current buffer. 392 | 393 | ------------------------------------------------------------------------------ 394 | *MiniGit.toggle()* 395 | `MiniGit.toggle`({buf_id}) 396 | Toggle Git tracking in buffer 397 | 398 | Enable if disabled, disable if enabled. 399 | 400 | Parameters ~ 401 | {buf_id} `(number)` Target buffer identifier. Default: 0 for current buffer. 402 | 403 | ------------------------------------------------------------------------------ 404 | *MiniGit.get_buf_data()* 405 | `MiniGit.get_buf_data`({buf_id}) 406 | Get buffer data 407 | 408 | Parameters ~ 409 | {buf_id} `(number)` Target buffer identifier. Default: 0 for current buffer. 410 | 411 | Return ~ 412 | `(table|nil)` Table with buffer Git data or `nil` if buffer is not enabled. 413 | If the file is not part of Git repo, table will be empty. 414 | Table has the following fields: 415 | - `(string)` - full path to '.git' directory. 416 | - `(string)` - full path to worktree root. 417 | - `(string)` - full commit of current HEAD. 418 | - `(string)` - short name of current HEAD (like "master"). 419 | For detached HEAD it is "HEAD". 420 | - `(string)` - two character file status as returned by `git status`. 421 | - `(string)` - name of action(s) currently in progress 422 | (bisect, merge, etc.). Can be a combination of those separated by ",". 423 | 424 | 425 | vim:tw=78:ts=8:noet:ft=help:norl: -------------------------------------------------------------------------------- /lua/mini/git.lua: -------------------------------------------------------------------------------- 1 | --- *mini.git* Git integration 2 | --- 3 | --- MIT License Copyright (c) 2024 Evgeni Chasnovski 4 | 5 | --- Features: 6 | --- 7 | --- - Automated tracking of Git related data: root path, status, HEAD, etc. 8 | --- Exposes buffer-local variables for convenient use in statusline. 9 | --- See |MiniGit.enable()| and |MiniGit.get_buf_data()| for more information. 10 | --- 11 | --- - |:Git| command for executing any `git` call inside file's repository root with 12 | --- deeper current instance integration (show output as notification/buffer, 13 | --- use to edit commit messages, etc.). 14 | --- 15 | --- - Helper functions to inspect Git history: 16 | --- - |MiniGit.show_range_history()| shows how certain line range evolved. 17 | --- - |MiniGit.show_diff_source()| shows file state as it was at diff entry. 18 | --- - |MiniGit.show_at_cursor()| shows Git related data depending on context. 19 | --- 20 | --- What it doesn't do: 21 | --- 22 | --- - Replace fully featured Git client. Rule of thumb: if feature does not rely 23 | --- on a state of current Neovim (opened buffers, etc.), it is out of scope. 24 | --- For more functionality, use either |mini.diff| or fully featured Git client. 25 | --- 26 | --- Sources with more details: 27 | --- - |:Git| 28 | --- - |MiniGit-examples| 29 | --- - |MiniGit.enable()| 30 | --- - |MiniGit.get_buf_data()| 31 | --- 32 | --- # Setup ~ 33 | --- 34 | --- This module needs a setup with `require('mini.git').setup({})` (replace `{}` with 35 | --- your `config` table). It will create global Lua table `MiniGit` which you can use 36 | --- for scripting or manually (with `:lua MiniGit.*`). 37 | --- 38 | --- See |MiniGit.config| for `config` structure and default values. 39 | --- 40 | --- # Comparisons ~ 41 | --- 42 | --- - [tpope/vim-fugitive](https://github.com/tpope/vim-fugitive): 43 | --- - Mostly a dedicated Git client, while this module is not (by design). 44 | --- - Provides buffer-local Git data only through fixed statusline component, 45 | --- while this module has richer data in the form of a Lua table. 46 | --- - Both provide |:Git| command with 'vim-fugitive' treating some cases 47 | --- extra specially (like `:Git blame`, etc.), while this module mostly 48 | --- treats all cases the same. See |MiniGit-examples| for how they can be 49 | --- manually customized. 50 | --- Also this module provides slightly different (usually richer) 51 | --- completion suggestions. 52 | --- 53 | --- - [NeogitOrg/neogit](https://github.com/NeogitOrg/neogit): 54 | --- - Similar to 'tpope/vim-fugitive', but without `:Git` command. 55 | --- 56 | --- - [lewis6991/gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim): 57 | --- - Provides buffer-local Git data with emphasis on granular diff status, 58 | --- while this module is more oriented towards repository and file level 59 | --- data (root, HEAD, file status, etc.). Use |mini.diff| for diff tracking. 60 | --- 61 | --- # Disabling ~ 62 | --- 63 | --- To prevent buffer(s) from being tracked, set `vim.g.minigit_disable` (globally) 64 | --- or `vim.b.minigit_disable` (for a buffer) to `true`. Considering high number of 65 | --- different scenarios and customization intentions, writing exact rules for 66 | --- disabling module's functionality is left to user. 67 | --- See |mini.nvim-disabling-recipes| for common recipes. 68 | ---@tag MiniGit 69 | 70 | --- # Statusline component ~ 71 | --- 72 | --- Tracked buffer data can be used in statusline via `vim.b.minigit_summary_string` 73 | --- buffer-local variable. It is expected to be used as is. To show another info, 74 | --- tweak buffer-local variable directly inside `MiniGitUpdated` `User` event: >lua 75 | --- 76 | --- -- Use only HEAD name as summary string 77 | --- local format_summary = function(data) 78 | --- -- Utilize buffer-local table summary 79 | --- local summary = vim.b[data.buf].minigit_summary 80 | --- vim.b[data.buf].minigit_summary_string = summary.head_name or '' 81 | --- end 82 | --- 83 | --- local au_opts = { pattern = 'MiniGitUpdated', callback = format_summary } 84 | --- vim.api.nvim_create_autocmd('User', au_opts) 85 | --- < 86 | --- # Tweaking command output ~ 87 | --- 88 | --- Buffer output of |:Git| command can be tweaked inside autocommand for 89 | --- `MiniGitCommandSplit` `User` event (see |MiniGit-command-events|). 90 | --- For example, to make `:vertical Git blame -- %` align blame output with the 91 | --- current window state, use the following code: >lua 92 | --- 93 | --- local align_blame = function(au_data) 94 | --- if au_data.data.git_subcommand ~= 'blame' then return end 95 | --- 96 | --- -- Align blame output with source 97 | --- local win_src = au_data.data.win_source 98 | --- vim.wo.wrap = false 99 | --- vim.fn.winrestview({ topline = vim.fn.line('w0', win_src) }) 100 | --- vim.api.nvim_win_set_cursor(0, { vim.fn.line('.', win_src), 0 }) 101 | --- 102 | --- -- Bind both windows so that they scroll together 103 | --- vim.wo[win_src].scrollbind, vim.wo.scrollbind = true, true 104 | --- end 105 | --- 106 | --- local au_opts = { pattern = 'MiniGitCommandSplit', callback = align_blame } 107 | --- vim.api.nvim_create_autocmd('User', au_opts) 108 | --- < 109 | --- # History navigation ~ 110 | --- 111 | --- Function |MiniGit.show_at_cursor()| is specifically exported to make Git 112 | --- history navigation easier. Here are some different ways it can be used: 113 | --- 114 | --- - Call inside buffer for already committed file to show the evolution of 115 | --- the current line (or visually selected range) through history. 116 | --- It is essentially a `:Git log HEAD` with proper `-L` flag. 117 | --- This also works inside output of |MiniGit.show_diff_source()|. 118 | --- 119 | --- - Call with cursor on commit hash to inspect that commit in full. 120 | --- This is usually helpful in the output of `:Git log`. 121 | --- 122 | --- - Call with cursor inside diff entry to inspect its file in the state how it 123 | --- was at certain commit. By default it shows state after commit, unless cursor 124 | --- is on the "deleted" line (i.e. line starting with "-") in which case 125 | --- state before commit is shown. 126 | --- 127 | --- This workflow can be made more interactive when used with mapping, like this: >lua 128 | --- 129 | --- local rhs = 'lua MiniGit.show_at_cursor()' 130 | --- vim.keymap.set({ 'n', 'x' }, 'gs', rhs, { desc = 'Show at cursor' }) 131 | --- < 132 | ---@tag MiniGit-examples 133 | 134 | --- The `:Git` user command runs `git` CLI call with extra integration for currently 135 | --- opened Neovim process: 136 | --- - Command is executed inside repository root of the currently active file 137 | --- (or |current-directory| if file is not tracked by this module). 138 | --- 139 | --- - Command output is shown either in dedicated buffer in window split or as 140 | --- notification via |vim.notify()|. Which method is used depends on whether 141 | --- particular Git subcommand is supposed to show data for user to inspect 142 | --- (like `log`, `status`, etc.) or not (like `commit`, `push`, etc.). This is 143 | --- determined automatically based on the data Git itself provides. 144 | --- Split window is made current after command execution. 145 | --- 146 | --- Use split-related |:command-modifiers| (|:vertical|, |:horizontal|, or |:tab|) 147 | --- to force output in a particular type of split. Default split direction is 148 | --- controlled by `command.split` in |MiniGit.config|. 149 | --- 150 | --- Use |:silent| command modifier to not show any output. 151 | --- 152 | --- Errors and warnings are always shown as notifications. 153 | --- 154 | --- See |MiniGit-examples| for the example of tweaking command output. 155 | --- 156 | --- - Editor for tasks that require interactive user input (like `:Git commit` or 157 | --- `:Git rebase --interactive`) is opened inside current session in a separate 158 | --- split. Make modifications as in regular buffer, |:write| changes followed by 159 | --- |:close| / |:quit| for Git CLI command to resume. 160 | --- 161 | --- Examples of usage: 162 | --- - `:Git log --oneline` - show compact log of current repository. 163 | --- - `:vert Git blame -- %` - show latest commits per line in vertical split. 164 | --- - `:Git help rebase` - show help page for `rebase` subcommand. 165 | --- - `:Git -C status` - execute `git status` inside |current-directory|. 166 | --- 167 | --- There is also a context aware completion which can be invoked with ``: 168 | --- - If completed word starts with "-", options for the current Git subcommand 169 | --- are shown. Like completion at `:Git log -` will suggest `-L`, `--oneline`, etc. 170 | --- - If there is an explicit " -- " to the cursor's left, incremental path 171 | --- suggestions will be shown. 172 | --- - If there is no recognized Git subcommand yet, show list of subcommands. 173 | --- Otherwise for some common subcommands list of its targets will be suggested: 174 | --- like for `:Git branch` it will be list of branches, etc. 175 | --- 176 | --- Notes: 177 | --- - Paths are always treated as relative to command's execution directory 178 | --- (file's repository root or |current-directory| if absent). 179 | --- - Don't use quotes for entries containing space, escape it with `\` directly. 180 | --- Like `:Git commit -m Hello\ world` and not `:Git commit -m 'Hello world'` 181 | --- (which treats `'Hello` and `world'` as separate arguments). 182 | --- 183 | --- # Events ~ 184 | --- *MiniGit-command-events* 185 | --- 186 | --- There are several `User` events triggered during command execution: 187 | --- 188 | --- - `MiniGitCommandDone` - after command is done executing. For Lua callbacks it 189 | --- provides a special `data` table with the following fields: 190 | --- - `(table)` - structured data about executed command. 191 | --- Has same structure as Lua function input in |nvim_create_user_command()|. 192 | --- - `(string)` - directory path inside which Git command was executed. 193 | --- - `(number)` - exit code of CLI process. 194 | --- - `(table)` - array with arguments of full executed command. 195 | --- - `(string)` - detected Git subcommand (like "log", etc.). 196 | --- - `(string)` - `stderr` process output. 197 | --- - `(string)` - `stdout` process output. 198 | --- 199 | --- - `MiniGitCommandSplit` - after command showed its output in a split. Triggered 200 | --- after `MiniGitCommandDone` and provides similar `data` table with extra fields: 201 | --- - `(number)` - window identifier of "source" window (current at 202 | --- the moment before command execution). 203 | --- - `(number)` - window identifier of command output. 204 | ---@tag :Git 205 | 206 | ---@alias __git_buf_id number Target buffer identifier. Default: 0 for current buffer. 207 | ---@alias __git_split_field `(string)` - split direction. One of "horizontal", "vertical", 208 | --- "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git' 209 | --- buffers are shown in the tabpage and |:tab| otherwise. 210 | 211 | ---@diagnostic disable:undefined-field 212 | ---@diagnostic disable:discard-returns 213 | ---@diagnostic disable:unused-local 214 | ---@diagnostic disable:cast-local-type 215 | ---@diagnostic disable:undefined-doc-name 216 | ---@diagnostic disable:luadoc-miss-type-name 217 | 218 | -- Module definition ========================================================== 219 | local MiniGit = {} 220 | local H = {} 221 | 222 | --- Module setup 223 | --- 224 | --- Besides general side effects (see |mini.nvim|), it also: 225 | --- - Sets up auto enabling in every normal buffer for an actual file on disk. 226 | --- - Creates |:Git| command. 227 | --- 228 | ---@param config table|nil Module config table. See |MiniGit.config|. 229 | --- 230 | ---@usage >lua 231 | --- require('mini.git').setup() -- use default config 232 | --- -- OR 233 | --- require('mini.git').setup({}) -- replace {} with your config table 234 | --- < 235 | MiniGit.setup = function(config) 236 | -- Export module 237 | _G.MiniGit = MiniGit 238 | 239 | -- Setup config 240 | config = H.setup_config(config) 241 | 242 | -- Apply config 243 | H.apply_config(config) 244 | 245 | -- Ensure proper Git executable 246 | local exec = config.job.git_executable 247 | H.has_git = vim.fn.executable(exec) == 1 248 | if not H.has_git then H.notify('There is no `' .. exec .. '` executable', 'WARN') end 249 | 250 | -- Define behavior 251 | H.create_autocommands() 252 | for _, buf_id in ipairs(vim.api.nvim_list_bufs()) do 253 | H.auto_enable({ buf = buf_id }) 254 | end 255 | 256 | -- Create user commands 257 | H.create_user_commands() 258 | end 259 | 260 | --stylua: ignore 261 | --- Defaults ~ 262 | ---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section) 263 | ---@text # Job ~ 264 | --- 265 | --- `config.job` contains options for customizing CLI executions. 266 | --- 267 | --- `job.git_executable` defines a full path to Git executable. Default: "git". 268 | --- 269 | --- `job.timeout` is a duration (in ms) from job start until it is forced to stop. 270 | --- Default: 30000. 271 | --- 272 | --- # Command ~ 273 | --- 274 | --- `config.command` contains options for customizing |:Git| command. 275 | --- 276 | --- `command.split` defines default split direction for |:Git| command output. Can be 277 | --- one of "horizontal", "vertical", "tab", or "auto". Value "auto" uses |:vertical| 278 | --- if only 'mini.git' buffers are shown in the tabpage and |:tab| otherwise. 279 | --- Default: "auto". 280 | MiniGit.config = { 281 | -- General CLI execution 282 | job = { 283 | -- Path to Git executable 284 | git_executable = 'git', 285 | 286 | -- Timeout (in ms) for each job before force quit 287 | timeout = 30000, 288 | }, 289 | 290 | -- Options for `:Git` command 291 | command = { 292 | -- Default split direction 293 | split = 'auto', 294 | }, 295 | } 296 | --minidoc_afterlines_end 297 | 298 | --- Show Git related data at cursor 299 | --- 300 | --- - If inside |mini.deps| confirmation buffer, show in split relevant commit data. 301 | --- - If there is a commit-like ||, show it in split. 302 | --- - If possible, show diff source via |MiniGit.show_diff_source()|. 303 | --- - If possible, show range history via |MiniGit.show_range_history()|. 304 | --- - Otherwise throw an error. 305 | --- 306 | ---@param opts table|nil Options. Possible values: 307 | --- - __git_split_field 308 | --- - Fields appropriate for forwarding to other functions. 309 | MiniGit.show_at_cursor = function(opts) 310 | -- Try showing commit data at cursor 311 | local commit, cwd 312 | if vim.bo.filetype == 'minideps-confirm' then 313 | commit, cwd = H.deps_pos_to_source() 314 | else 315 | local cword = vim.fn.expand('') 316 | local is_commit = string.find(cword, '^%x%x%x%x%x%x%x+$') ~= nil and string.lower(cword) == cword 317 | commit = is_commit and cword or nil 318 | cwd = is_commit and H.get_git_cwd() or nil 319 | end 320 | 321 | if commit ~= nil and cwd ~= nil then 322 | local split = H.normalize_split_opt((opts or {}).split or 'auto', 'opts.split') 323 | local args = { 'show', '--stat', '--patch', commit } 324 | local lines = H.git_cli_output(args, cwd) 325 | if #lines == 0 then return H.notify('Can not show commit ' .. commit .. ' in repo ' .. cwd, 'WARN') end 326 | H.show_in_split(split, lines, 'show', table.concat(args, ' ')) 327 | vim.bo.filetype = 'git' 328 | return 329 | end 330 | 331 | -- Try showing diff source 332 | if H.diff_pos_to_source() ~= nil then return MiniGit.show_diff_source(opts) end 333 | 334 | -- Try showing range history if possible: either in Git repo (tracked or not; 335 | -- after resolving symlinks) or diff source output. 336 | local buf_id, buf_name = vim.api.nvim_get_current_buf(), vim.api.nvim_buf_get_name(0) 337 | local path = vim.loop.fs_realpath(buf_name) 338 | local path_dir = path == nil and '' or vim.fn.fnamemodify(path, ':h') 339 | 340 | local is_in_git = H.is_buf_enabled(buf_id) or #H.git_cli_output({ 'rev-parse', '--show-toplevel' }, path_dir) > 0 341 | local is_diff_source_output = H.parse_diff_source_buf_name(buf_name) ~= nil 342 | if is_in_git or is_diff_source_output then return MiniGit.show_range_history(opts) end 343 | 344 | H.notify('Nothing Git-related to show at cursor', 'WARN') 345 | end 346 | 347 | --- Show diff source 348 | --- 349 | --- When buffer contains text formatted as unified patch (like after 350 | --- `:Git log --patch`, `:Git diff`, or |MiniGit.show_range_history()|), 351 | --- show state of the file at the particular state. Target commit/state, path, 352 | --- and line number are deduced from cursor position. 353 | --- 354 | --- Notes: 355 | --- - Needs |current-directory| to be the Git root for relative paths to work. 356 | --- - Needs cursor to be inside hunk lines or on "---" / "+++" lines with paths. 357 | --- - Only basic forms of `:Git diff` output is supported: `:Git diff`, 358 | --- `:Git diff --cached`, and `:Git diff `. 359 | --- 360 | ---@param opts table|nil Options. Possible values: 361 | --- - __git_split_field 362 | --- - `(string)` - which file state to show. One of "before", "after", 363 | --- "both" (both states in vertical split), "auto" (default). Value "auto" 364 | --- shows "before" state if cursor line starts with "-", otherwise - "after". 365 | MiniGit.show_diff_source = function(opts) 366 | opts = vim.tbl_deep_extend('force', { split = 'auto', target = 'auto' }, opts or {}) 367 | local split = H.normalize_split_opt(opts.split, 'opts.split') 368 | local target = opts.target 369 | if not (target == 'auto' or target == 'before' or target == 'after' or target == 'both') then 370 | H.error('`opts.target` should be one of "auto", "before", "after", "both".') 371 | end 372 | 373 | local src = H.diff_pos_to_source() 374 | if src == nil then 375 | return H.notify('Could not find diff source. Ensure that cursor is inside a valid diff lines of git log.', 'WARN') 376 | end 377 | if target == 'auto' then target = src.init_prefix == '-' and 'before' or 'after' end 378 | 379 | local cwd = H.get_git_cwd() 380 | local show = function(commit, path, mods) 381 | local is_worktree, args, lines = commit == true, nil, nil 382 | if is_worktree then 383 | args, lines = { 'edit', vim.fn.fnameescape(path) }, vim.fn.readfile(path) 384 | else 385 | args = { 'show', commit .. ':' .. path } 386 | lines = H.git_cli_output(args, cwd) 387 | end 388 | if #lines == 0 and not is_worktree then 389 | return H.notify('Can not show ' .. path .. ' at commit ' .. commit, 'WARN') 390 | end 391 | H.show_in_split(mods, lines, 'show', table.concat(args, ' ')) 392 | end 393 | 394 | local has_before_shown = false 395 | if target ~= 'after' then 396 | if src.path_before == nil then 397 | H.notify('No "before" as file was created', 'WARN') 398 | else 399 | show(src.commit_before, src.path_before, split) 400 | vim.api.nvim_win_set_cursor(0, { src.lnum_before, 0 }) 401 | has_before_shown = true 402 | end 403 | end 404 | 405 | if target ~= 'before' then 406 | if src.path_after == nil then 407 | H.notify('No "after" as file was deleted', 'WARN') 408 | else 409 | local mods_after = has_before_shown and 'belowright vertical' or split 410 | show(src.commit_after, src.path_after, mods_after) 411 | vim.api.nvim_win_set_cursor(0, { src.lnum_after, 0 }) 412 | end 413 | end 414 | end 415 | 416 | --- Show range history 417 | --- 418 | --- Compute and show in split data about how particular line range in current 419 | --- buffer evolved through Git history. Essentially a `git log` with `-L` flag. 420 | --- 421 | --- Notes: 422 | --- - Works well with |MiniGit.diff_foldexpr()|. 423 | --- - Does not work if there are uncommited changes, as there is no easy way to 424 | --- compute effective range line numbers. 425 | --- 426 | ---@param opts table|nil Options. Possible fields: 427 | --- - `(number)` - range start line. 428 | --- - `(number)` - range end line. 429 | --- If both and are not supplied, they default to 430 | --- current line in Normal mode and visual selection in Visual mode. 431 | --- - `(table)` - array of options to append to `git log` call. 432 | --- - __git_split_field 433 | MiniGit.show_range_history = function(opts) 434 | local default_opts = { line_start = nil, line_end = nil, log_args = nil, split = 'auto' } 435 | opts = vim.tbl_deep_extend('force', default_opts, opts or {}) 436 | local line_start, line_end = H.normalize_range_lines(opts.line_start, opts.line_end) 437 | local log_args = opts.log_args or {} 438 | if not H.islist(log_args) then H.error('`opts.log_args` should be an array.') end 439 | local split = H.normalize_split_opt(opts.split, 'opts.split') 440 | 441 | -- Construct `:Git log` command that works both with regular files and 442 | -- buffers from `show_diff_source()` 443 | local buf_name, cwd = vim.api.nvim_buf_get_name(0), H.get_git_cwd() 444 | local commit, rel_path = H.parse_diff_source_buf_name(buf_name) 445 | if commit == nil then 446 | commit = 'HEAD' 447 | local cwd_pattern = '^' .. vim.pesc(cwd:gsub('\\', '/')) .. '/' 448 | rel_path = H.get_buf_realpath(0):gsub('\\', '/'):gsub(cwd_pattern, '') 449 | end 450 | 451 | -- Ensure no uncommitted changes as they might result into improper `-L` arg 452 | local diff = commit == 'HEAD' and H.git_cli_output({ 'diff', '-U0', 'HEAD', '--', rel_path }, cwd) or {} 453 | if #diff ~= 0 then 454 | return H.notify('Current file has uncommitted lines. Commit or stash before exploring history.', 'WARN') 455 | end 456 | 457 | -- Show log in split 458 | local range_flag = string.format('-L%d,%d:%s', line_start, line_end, rel_path) 459 | local args = { 'log', range_flag, commit, unpack(log_args) } 460 | local history = H.git_cli_output(args, cwd) 461 | if #history == 0 then return H.notify('Could not get range history', 'WARN') end 462 | H.show_in_split(split, history, 'log', table.concat(args, ' ')) 463 | end 464 | 465 | --- Fold expression for Git logs 466 | --- 467 | --- Folds contents of hunks, file patches, and log entries in unified diff. 468 | --- Useful for filetypes "diff" (like after `:Git diff`) and "git" (like after 469 | --- `:Git log --patch` or `:Git show` for commit). 470 | --- Works well with |MiniGit.show_range_history()|. 471 | --- 472 | --- General idea of folding levels (use |zr| and |zm| to adjust interactively): 473 | --- - At level 0 there is one line per whole patch or log entry. 474 | --- - At level 1 there is one line per patched file. 475 | --- - At level 2 there is one line per hunk. 476 | --- - At level 3 there is no folds. 477 | --- 478 | --- For automated setup, set the following for "git" and "diff" filetypes (either 479 | --- inside |FileType| autocommand or |ftplugin|): >vim 480 | --- 481 | --- setlocal foldmethod=expr foldexpr=v:lua.MiniGit.diff_foldexpr() 482 | --- < 483 | ---@param lnum number|nil Line number for which fold level is computed. 484 | --- Default: |v:lnum|. 485 | --- 486 | ---@return number|string Line fold level. See |fold-expr|. 487 | MiniGit.diff_foldexpr = function(lnum) 488 | lnum = lnum or vim.v.lnum 489 | if H.is_log_entry_header(lnum + 1) or H.is_log_entry_header(lnum) then return 0 end 490 | if H.is_file_entry_header(lnum) then return 1 end 491 | if H.is_hunk_header(lnum) then return 2 end 492 | if H.is_hunk_header(lnum - 1) then return 3 end 493 | return '=' 494 | end 495 | 496 | --- Enable Git tracking in a file buffer 497 | --- 498 | --- Tracking is done by reacting to changes in file content or file's repository 499 | --- in the form of keeping buffer data up to date. The data can be used via: 500 | --- - |MiniGit.get_buf_data()|. See its help for a list of actually tracked data. 501 | --- - `vim.b.minigit_summary` (table) and `vim.b.minigit_summary_string` (string) 502 | --- buffer-local variables which are more suitable for statusline. 503 | --- `vim.b.minigit_summary_string` contains information about HEAD, file status, 504 | --- and in progress action (see |MiniGit.get_buf_data()| for more details). 505 | --- See |MiniGit-examples| for how it can be tweaked and used in statusline. 506 | --- 507 | --- Note: this function is called automatically for all new normal buffers. 508 | --- Use it explicitly if buffer was disabled. 509 | --- 510 | --- `User` event `MiniGitUpdated` is triggered whenever tracking data is updated. 511 | --- Note that not all data listed in |MiniGit.get_buf_data()| can be present (yet) 512 | --- at the point of event being triggered. 513 | --- 514 | ---@param buf_id __git_buf_id 515 | MiniGit.enable = function(buf_id) 516 | buf_id = H.validate_buf_id(buf_id) 517 | 518 | -- Don't enable more than once 519 | if H.is_buf_enabled(buf_id) or H.is_disabled(buf_id) or not H.has_git then return end 520 | 521 | -- Enable only in buffers which *can* be part of Git repo 522 | local path = H.get_buf_realpath(buf_id) 523 | if path == '' or vim.fn.filereadable(path) ~= 1 then return end 524 | 525 | -- Start tracking 526 | H.cache[buf_id] = {} 527 | H.setup_buf_behavior(buf_id) 528 | H.start_tracking(buf_id, path) 529 | end 530 | 531 | --- Disable Git tracking in buffer 532 | --- 533 | ---@param buf_id __git_buf_id 534 | MiniGit.disable = function(buf_id) 535 | buf_id = H.validate_buf_id(buf_id) 536 | 537 | local buf_cache = H.cache[buf_id] 538 | if buf_cache == nil then return end 539 | H.cache[buf_id] = nil 540 | 541 | -- Cleanup 542 | pcall(vim.api.nvim_del_augroup_by_id, buf_cache.augroup) 543 | vim.b[buf_id].minigit_summary, vim.b[buf_id].minigit_summary_string = nil, nil 544 | 545 | -- - Unregister buffer from repo watching with possibly more cleanup 546 | local repo = buf_cache.repo 547 | if H.repos[repo] == nil then return end 548 | H.repos[repo].buffers[buf_id] = nil 549 | if vim.tbl_count(H.repos[repo].buffers) == 0 then 550 | H.teardown_repo_watch(repo) 551 | H.repos[repo] = nil 552 | end 553 | end 554 | 555 | --- Toggle Git tracking in buffer 556 | --- 557 | --- Enable if disabled, disable if enabled. 558 | --- 559 | ---@param buf_id __git_buf_id 560 | MiniGit.toggle = function(buf_id) 561 | buf_id = H.validate_buf_id(buf_id) 562 | if H.is_buf_enabled(buf_id) then return MiniGit.disable(buf_id) end 563 | return MiniGit.enable(buf_id) 564 | end 565 | 566 | --- Get buffer data 567 | --- 568 | ---@param buf_id __git_buf_id 569 | --- 570 | ---@return table|nil Table with buffer Git data or `nil` if buffer is not enabled. 571 | --- If the file is not part of Git repo, table will be empty. 572 | --- Table has the following fields: 573 | --- - `(string)` - full path to '.git' directory. 574 | --- - `(string)` - full path to worktree root. 575 | --- - `(string)` - full commit of current HEAD. 576 | --- - `(string)` - short name of current HEAD (like "master"). 577 | --- For detached HEAD it is "HEAD". 578 | --- - `(string)` - two character file status as returned by `git status`. 579 | --- - `(string)` - name of action(s) currently in progress 580 | --- (bisect, merge, etc.). Can be a combination of those separated by ",". 581 | MiniGit.get_buf_data = function(buf_id) 582 | buf_id = H.validate_buf_id(buf_id) 583 | local buf_cache = H.cache[buf_id] 584 | if buf_cache == nil then return nil end 585 | --stylua: ignore 586 | return { 587 | repo = buf_cache.repo, root = buf_cache.root, 588 | head = buf_cache.head, head_name = buf_cache.head_name, 589 | status = buf_cache.status, in_progress = buf_cache.in_progress, 590 | } 591 | end 592 | 593 | -- Helper data ================================================================ 594 | -- Module default config 595 | H.default_config = MiniGit.config 596 | 597 | -- Cache per enabled buffer. Values are tables with fields: 598 | -- - - identifier of augroup defining buffer behavior. 599 | -- - - path to buffer's repo ('.git' directory). 600 | -- - - path to worktree root. 601 | -- - - full commit of `HEAD`. 602 | -- - - short name of `HEAD` (`'HEAD'` for detached head). 603 | -- - - current file status. 604 | -- - - string name of action in progress (bisect, merge, etc.) 605 | H.cache = {} 606 | 607 | -- Cache per repo (git directory) path. Values are tables with fields: 608 | -- - - `vim.loop` event for watching repo dir. 609 | -- - - timer to debounce repo changes. 610 | -- - - map of buffers which should are part of repo. 611 | H.repos = {} 612 | 613 | -- Termporary file used as config for `GIT_EDITOR` 614 | H.git_editor_config = nil 615 | 616 | -- Data about supported Git subcommands. Initialized lazily. Fields: 617 | -- - - array of supported one word commands. 618 | -- - - array of commands to complete directly after `:Git`. 619 | -- - - map with fields as commands which show something to user. 620 | -- - - map of cached options per command; initialized lazily. 621 | -- - - map of alias command name to command it implements. 622 | H.git_subcommands = nil 623 | 624 | -- Whether to temporarily skip some checks (like when inside `GIT_EDITOR`) 625 | H.skip_timeout = false 626 | H.skip_sync = false 627 | 628 | -- Helper functionality ======================================================= 629 | -- Settings ------------------------------------------------------------------- 630 | H.setup_config = function(config) 631 | H.check_type('config', config, 'table', true) 632 | config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {}) 633 | 634 | H.check_type('job', config.job, 'table') 635 | H.check_type('command', config.command, 'table') 636 | 637 | H.check_type('job.git_executable', config.job.git_executable, 'string') 638 | H.check_type('job.timeout', config.job.timeout, 'number') 639 | if not pcall(H.normalize_split_opt, config.command.split) then 640 | H.error('`command.split` should be one of "auto", "horizontal", "vertical", "tab"') 641 | end 642 | 643 | return config 644 | end 645 | 646 | H.apply_config = function(config) MiniGit.config = config end 647 | 648 | H.create_autocommands = function() 649 | local gr = vim.api.nvim_create_augroup('MiniGit', {}) 650 | 651 | local au = function(event, pattern, callback, desc) 652 | vim.api.nvim_create_autocmd(event, { group = gr, pattern = pattern, callback = callback, desc = desc }) 653 | end 654 | 655 | -- NOTE: Try auto enabling buffer on every `BufEnter` to not have `:edit` 656 | -- disabling buffer, as it calls `on_detach()` from buffer watcher 657 | au('BufEnter', '*', H.auto_enable, 'Enable Git tracking') 658 | end 659 | 660 | H.is_disabled = function(buf_id) return vim.g.minigit_disable == true or vim.b[buf_id or 0].minigit_disable == true end 661 | 662 | H.create_user_commands = function() 663 | local opts = { bang = true, nargs = '+', complete = H.command_complete, desc = 'Execute Git command' } 664 | vim.api.nvim_create_user_command('Git', H.command_impl, opts) 665 | end 666 | 667 | -- Autocommands --------------------------------------------------------------- 668 | H.auto_enable = vim.schedule_wrap(function(data) 669 | local buf = data.buf 670 | if not (vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == '' and vim.bo[buf].buflisted) then return end 671 | MiniGit.enable(data.buf) 672 | end) 673 | 674 | -- Command -------------------------------------------------------------------- 675 | H.command_impl = function(input) 676 | if not H.has_git then 677 | return H.notify('There is no `' .. MiniGit.config.job.git_executable .. '` executable', 'ERROR') 678 | end 679 | 680 | H.ensure_git_subcommands() 681 | 682 | -- Define Git editor to be used if needed. The way it works is: execute 683 | -- command, wait for it to exit, use content of edited file. So to properly 684 | -- wait for user to finish edit, start fresh headless process which opens 685 | -- file in current session/process. It exits after the user is done editing 686 | -- (deletes the buffer or closes the window). 687 | H.ensure_git_editor(input.mods) 688 | -- NOTE: use `vim.v.progpath` to have same runtime 689 | local editor = H.cli_escape(vim.v.progpath) .. ' --clean --headless -u ' .. H.cli_escape(H.git_editor_config) 690 | 691 | -- Setup custom environment variables for better reproducibility 692 | local env_vars = {} 693 | -- - Use Git related variables to use instance for editing 694 | env_vars.GIT_EDITOR, env_vars.GIT_SEQUENCE_EDITOR, env_vars.GIT_PAGER = editor, editor, '' 695 | -- - Make output as much machine readable as possible 696 | env_vars.NO_COLOR, env_vars.TERM = 1, 'dumb' 697 | local env = H.make_spawn_env(env_vars) 698 | 699 | -- Setup spawn arguments 700 | local args = vim.tbl_map(H.expandcmd, input.fargs) 701 | local command = { MiniGit.config.job.git_executable, unpack(args) } 702 | local cwd = H.get_git_cwd() 703 | 704 | local cmd_data = { cmd_input = input, git_command = command, cwd = cwd } 705 | local is_done_track = { done = false } 706 | local on_done = H.command_make_on_done(cmd_data, is_done_track) 707 | 708 | H.cli_run(command, cwd, on_done, { env = env }) 709 | 710 | -- If needed, synchronously wait for job to finish 711 | local sync_check = function() return H.skip_sync or is_done_track.done end 712 | if not input.bang then vim.wait(MiniGit.config.job.timeout + 10, sync_check, 1) end 713 | end 714 | 715 | --stylua: ignore 716 | H.ensure_git_subcommands = function() 717 | if H.git_subcommands ~= nil then return end 718 | local git_subcommands = {} 719 | 720 | -- Compute all supported commands. All 'list-' are taken from Git source 721 | -- 'command-list.txt' file. Be so granular and not just `main,nohelpers` in 722 | -- order to not include purely man-page worthy items (like "remote-ext"). 723 | local lists_all = { 724 | 'list-mainporcelain', 725 | 'list-ancillarymanipulators', 'list-ancillaryinterrogators', 726 | 'list-foreignscminterface', 727 | 'list-plumbingmanipulators', 'list-plumbinginterrogators', 728 | 'others', 'alias', 729 | } 730 | local supported = H.git_cli_output({ '--list-cmds=' .. table.concat(lists_all, ',') }) 731 | if #supported == 0 then 732 | -- Fall back only on basics if previous one failed for some reason 733 | supported = { 734 | 'add', 'bisect', 'branch', 'clone', 'commit', 'diff', 'fetch', 'grep', 'init', 'log', 'merge', 735 | 'mv', 'pull', 'push', 'rebase', 'reset', 'restore', 'rm', 'show', 'status', 'switch', 'tag', 736 | } 737 | end 738 | table.sort(supported) 739 | git_subcommands.supported = supported 740 | 741 | -- Compute complete list for commands by enhancing with two word commands. 742 | -- Keep those lists manual as there is no good way to compute lazily. 743 | local complete = vim.deepcopy(supported) 744 | local add_twoword = function(prefix, suffixes) 745 | if not vim.tbl_contains(supported, prefix) then return end 746 | for _, suf in ipairs(suffixes) do table.insert(complete, prefix .. ' ' .. suf) end 747 | end 748 | add_twoword('bundle', { 'create', 'list-heads', 'unbundle', 'verify' }) 749 | add_twoword('bisect', { 'bad', 'good', 'log', 'replay', 'reset', 'run', 'skip', 'start', 'terms', 'view', 'visualize' }) 750 | add_twoword('commit-graph', { 'verify', 'write' }) 751 | add_twoword('maintenance', { 'run', 'start', 'stop', 'register', 'unregister' }) 752 | add_twoword('multi-pack-index', { 'expire', 'repack', 'verify', 'write' }) 753 | add_twoword('notes', { 'add', 'append', 'copy', 'edit', 'get-ref', 'list', 'merge', 'prune', 'remove', 'show' }) 754 | add_twoword('p4', { 'clone', 'rebase', 'submit', 'sync' }) 755 | add_twoword('reflog', { 'delete', 'exists', 'expire', 'show' }) 756 | add_twoword('remote', { 'add', 'get-url', 'prune', 'remove', 'rename', 'rm', 'set-branches', 'set-head', 'set-url', 'show', 'update' }) 757 | add_twoword('rerere', { 'clear', 'diff', 'forget', 'gc', 'remaining', 'status' }) 758 | add_twoword('sparse-checkout', { 'add', 'check-rules', 'disable', 'init', 'list', 'reapply', 'set' }) 759 | add_twoword('stash', { 'apply', 'branch', 'clear', 'create', 'drop', 'list', 'pop', 'save', 'show', 'store' }) 760 | add_twoword('submodule', { 'absorbgitdirs', 'add', 'deinit', 'foreach', 'init', 'set-branch', 'set-url', 'status', 'summary', 'sync', 'update' }) 761 | add_twoword('subtree', { 'add', 'merge', 'pull', 'push', 'split' }) 762 | add_twoword('worktree', { 'add', 'list', 'lock', 'move', 'prune', 'remove', 'repair', 'unlock' }) 763 | git_subcommands.complete = complete 764 | 765 | -- Compute commands which are meant to show information. These will show CLI 766 | -- output in separate buffer opposed to `vim.notify`. 767 | local info_args = { '--list-cmds=list-info,list-ancillaryinterrogators,list-plumbinginterrogators' } 768 | local info_commands = H.git_cli_output(info_args) 769 | if #info_commands == 0 then info_commands = { 'bisect', 'diff', 'grep', 'log', 'show', 'status' } end 770 | local info = {} 771 | for _, cmd in ipairs(info_commands) do 772 | info[cmd] = true 773 | end 774 | git_subcommands.info = info 775 | 776 | -- Compute commands which aliases rely on 777 | local alias_data = H.git_cli_output({ 'config', '--get-regexp', 'alias.*' }) 778 | local alias = {} 779 | for _, l in ipairs(alias_data) do 780 | -- Assume simple alias of the form `alias.xxx subcommand ...` 781 | local alias_cmd, cmd = string.match(l, '^alias%.(%S+) (%S+)') 782 | if vim.tbl_contains(supported, cmd) then alias[alias_cmd] = cmd end 783 | end 784 | git_subcommands.alias = alias 785 | 786 | -- Initialize cache for command options. Initialize with `false` so that 787 | -- actual values are computed lazily when needed for a command. 788 | local options = { git = false } 789 | for _, command in ipairs(supported) do 790 | options[command] = false 791 | end 792 | git_subcommands.options = options 793 | 794 | -- Cache results 795 | H.git_subcommands = git_subcommands 796 | end 797 | 798 | H.ensure_git_editor = function(mods) 799 | if H.git_editor_config == nil or not vim.fn.filereadable(H.git_editor_config) == 0 then 800 | H.git_editor_config = vim.fn.tempname() 801 | end 802 | 803 | -- Create a private function responsible for editing Git file 804 | MiniGit._edit = function(path, servername) 805 | -- Define editor state before and after editing path 806 | H.skip_timeout, H.skip_sync = true, true 807 | local cleanup = function() 808 | local _, channel = pcall(vim.fn.sockconnect, 'pipe', servername, { rpc = true }) 809 | pcall(vim.rpcnotify, channel, 'nvim_exec2', 'quitall!', {}) 810 | H.skip_timeout, H.skip_sync = false, false 811 | end 812 | 813 | -- Start file edit with proper modifiers in a special window 814 | mods = H.ensure_mods_is_split(mods) 815 | vim.cmd(mods .. ' split ' .. vim.fn.fnameescape(path)) 816 | H.define_minigit_window(cleanup) 817 | end 818 | 819 | -- Start editing file from first argument (as how `GIT_EDITOR` works) in 820 | -- current instance and don't close until explicitly closed later from this 821 | -- instance as set up in `MiniGit._edit()` 822 | local lines = { 823 | 'lua << EOF', 824 | string.format('local channel = vim.fn.sockconnect("pipe", %s, { rpc = true })', vim.inspect(vim.v.servername)), 825 | 'local ins = vim.inspect', 826 | 'local lua_cmd = string.format("MiniGit._edit(%s, %s)", ins(vim.fn.argv(0)), ins(vim.v.servername))', 827 | 'vim.rpcrequest(channel, "nvim_exec_lua", lua_cmd, {})', 828 | 'EOF', 829 | } 830 | vim.fn.writefile(lines, H.git_editor_config) 831 | end 832 | 833 | H.get_git_cwd = function() 834 | local buf_cache = H.cache[vim.api.nvim_get_current_buf()] or {} 835 | return buf_cache.root or vim.fn.getcwd() 836 | end 837 | 838 | H.command_make_on_done = function(cmd_data, is_done_track) 839 | return vim.schedule_wrap(function(code, out, err) 840 | -- Register that command is done executing (to enable sync execution) 841 | is_done_track.done = true 842 | 843 | -- Trigger "done" event 844 | cmd_data.git_subcommand = H.command_parse_subcommand(cmd_data.git_command) 845 | cmd_data.exit_code, cmd_data.stdout, cmd_data.stderr = code, out, err 846 | H.trigger_event('MiniGitCommandDone', cmd_data) 847 | 848 | -- Show stderr and stdout 849 | if H.cli_err_notify(code, out, err) then return end 850 | H.command_show_stdout(cmd_data) 851 | 852 | -- Ensure that all buffers are up to date (avoids "The file has been 853 | -- changed since reading it" warning) 854 | vim.tbl_map(function(buf_id) vim.cmd('checktime ' .. buf_id) end, vim.api.nvim_list_bufs()) 855 | end) 856 | end 857 | 858 | H.command_show_stdout = function(cmd_data) 859 | local stdout, mods, subcommand = cmd_data.stdout, cmd_data.cmd_input.mods, cmd_data.git_subcommand 860 | if stdout == '' or (mods:find('silent') ~= nil and mods:find('unsilent') == nil) then return end 861 | 862 | -- Show in split if explicitly forced or the command shows info. 863 | -- Use `vim.notify` otherwise. 864 | local should_split = H.mods_is_split(mods) or H.git_subcommands.info[subcommand] 865 | if not should_split then return H.notify(stdout, 'INFO') end 866 | 867 | local lines = vim.split(stdout, '\n') 868 | local name = table.concat(cmd_data.git_command, ' ') 869 | cmd_data.win_source, cmd_data.win_stdout = H.show_in_split(mods, lines, subcommand, name) 870 | 871 | -- Trigger "split" event 872 | H.trigger_event('MiniGitCommandSplit', cmd_data) 873 | end 874 | 875 | H.command_parse_subcommand = function(command) 876 | local res 877 | for _, cmd in ipairs(command) do 878 | if res == nil and vim.tbl_contains(H.git_subcommands.supported, cmd) then res = cmd end 879 | end 880 | return H.git_subcommands.alias[res] or res 881 | end 882 | 883 | H.command_complete = function(_, line, col) 884 | -- Compute completion base manually to be "at cursor" and respect `\ ` 885 | local base = H.get_complete_base(line:sub(1, col)) 886 | local candidates, compl_type = H.command_get_complete_candidates(line, col, base) 887 | -- Allow several "//" at the end for path completion for easier "chaining" 888 | if compl_type == 'path' then base = base:gsub('/+$', '/') end 889 | return vim.tbl_filter(function(x) return vim.startswith(x, base) end, candidates) 890 | end 891 | 892 | H.get_complete_base = function(line) 893 | local from, _, res = line:find('(%S*)$') 894 | while from ~= nil do 895 | local cur_from, _, cur_res = line:sub(1, from - 1):find('(%S*\\ )$') 896 | if cur_res ~= nil then res = cur_res .. res end 897 | from = cur_from 898 | end 899 | return (res:gsub([[\ ]], ' ')) 900 | end 901 | 902 | H.command_get_complete_candidates = function(line, col, base) 903 | H.ensure_git_subcommands() 904 | 905 | -- Determine current Git subcommand as the earliest present supported one 906 | local subcmd, subcmd_end = nil, math.huge 907 | for _, cmd in pairs(H.git_subcommands.supported) do 908 | local _, ind = line:find(' ' .. cmd .. ' ', 1, true) 909 | if ind ~= nil and ind < subcmd_end then 910 | subcmd, subcmd_end = cmd, ind 911 | end 912 | end 913 | 914 | subcmd = subcmd or 'git' 915 | local cwd = H.get_git_cwd() 916 | 917 | -- Determine command candidates: 918 | -- - Commannd options if complete base starts with "-". 919 | -- - Paths if after explicit "--". 920 | -- - Git commands if there is none fully formed yet or cursor is at the end 921 | -- of the command (to also suggest subcommands). 922 | -- - Command targets specific for each command (if present). 923 | if vim.startswith(base, '-') then return H.command_complete_option(subcmd) end 924 | if line:sub(1, col):find(' -- ') ~= nil then return H.command_complete_path(cwd, base) end 925 | if subcmd_end == math.huge or (subcmd_end - 1) == col then return H.git_subcommands.complete, 'subcommand' end 926 | 927 | subcmd = H.git_subcommands.alias[subcmd] or subcmd 928 | local complete_targets = H.command_complete_subcommand_targets[subcmd] 929 | if complete_targets == nil then return {}, nil end 930 | return complete_targets(cwd, base, line) 931 | end 932 | 933 | H.command_complete_option = function(command) 934 | local cached_candidates = H.git_subcommands.options[command] 935 | if cached_candidates == nil then return {} end 936 | if type(cached_candidates) == 'table' then return cached_candidates end 937 | 938 | -- Use alias's command to compute the options but store cache for alias 939 | local orig_command = command 940 | command = H.git_subcommands.alias[command] or command 941 | 942 | -- Find command's flag options by parsing its help page. Needs a bit 943 | -- heuristic approach and ensuring proper `git help` output (as it is done 944 | -- through `man`), but seems to work good enough. 945 | -- Alternative is to call command with `--git-completion-helper-all` flag (as 946 | -- is done in bash and vim-fugitive completion). This has both pros and cons: 947 | -- - Pros: faster; more targeted suggestions (like for two word subcommands); 948 | -- presumably more reliable. 949 | -- - Cons: works on smaller number of commands (for example, `rev-parse` or 950 | -- pure `git` do not work); does not provide single dash suggestions; 951 | -- does not work when not inside Git repo; needs recognizing two word 952 | -- commands before asking for completion. 953 | local env = H.make_spawn_env({ MANPAGER = 'cat', NO_COLOR = 1, PAGER = 'cat' }) 954 | local lines = H.git_cli_output({ 'help', '--man', command }, nil, env) 955 | -- - Exit early before caching to try again later 956 | if #lines == 0 then return {} end 957 | -- - On some systems (like Mac), output still might contain formatting 958 | -- sequences, like "a\ba" and "_\ba" meaning bold and italic. 959 | -- See https://github.com/nvim-mini/mini.nvim/issues/918 960 | lines = vim.tbl_map(function(l) return l:gsub('.\b', '') end, lines) 961 | 962 | -- Construct non-duplicating candidates by parsing lines of help page 963 | local candidates_map = {} 964 | 965 | -- Options are assumed to be listed inside "OPTIONS" or "XXX OPTIONS" (like 966 | -- "MODE OPTIONS" of `git rebase`) section on dedicated lines. Whether a line 967 | -- contains only options is determined heuristically: it is assumed to start 968 | -- exactly with " -" indicating proper indent for subsection start. 969 | -- Known not parsable options: 970 | -- - `git reset ` (--soft, --hard, etc.): not listed in "OPTIONS". 971 | -- - All - options, as they are not really completeable. 972 | local is_in_options_section = false 973 | for _, l in ipairs(lines) do 974 | if is_in_options_section and l:find('^%u[%u ]+$') ~= nil then is_in_options_section = false end 975 | if not is_in_options_section and l:find('^%u?[%u ]*OPTIONS$') ~= nil then is_in_options_section = true end 976 | if is_in_options_section and l:find('^ %-') ~= nil then H.parse_options(candidates_map, l) end 977 | end 978 | 979 | -- Finalize candidates. Should not contain "almost duplicates". 980 | -- Should also be sorted by relevance: short flags before regular flags. 981 | -- Inside groups sort alphabetically ignoring case. 982 | candidates_map['--'] = nil 983 | for cmd, _ in pairs(candidates_map) do 984 | -- There can be two explicitly documented options "--xxx" and "--xxx=". 985 | -- Use only one of them (without "="). 986 | if cmd:sub(-1, -1) == '=' and candidates_map[cmd:sub(1, -2)] ~= nil then candidates_map[cmd] = nil end 987 | end 988 | 989 | local res = vim.tbl_keys(candidates_map) 990 | table.sort(res, function(a, b) 991 | local a2, b2 = a:sub(2, 2) == '-', b:sub(2, 2) == '-' 992 | if a2 and not b2 then return false end 993 | if not a2 and b2 then return true end 994 | local a_low, b_low = a:lower(), b:lower() 995 | return a_low < b_low or (a_low == b_low and a < b) 996 | end) 997 | 998 | -- Cache and return 999 | H.git_subcommands.options[orig_command] = res 1000 | return res, 'option' 1001 | end 1002 | 1003 | H.parse_options = function(map, line) 1004 | -- Options are standalone words starting as "-xxx" or "--xxx" 1005 | -- Include possible "=" at the end indicating mandatory value 1006 | line:gsub('%s(%-[-%w][-%w]*=?)', function(match) map[match] = true end) 1007 | 1008 | -- Make exceptions for commonly documented "--[no-]xxx" two options 1009 | line:gsub('%s%-%-%[no%-%]([-%w]+=?)', function(match) 1010 | map['--' .. match], map['--no-' .. match] = true, true 1011 | end) 1012 | end 1013 | 1014 | H.command_complete_path = function(cwd, base) 1015 | -- Treat base only as path relative to the command's cwd 1016 | cwd = cwd:gsub('/+$', '') .. '/' 1017 | local cwd_len = cwd:len() 1018 | 1019 | -- List elements from (absolute) target directory 1020 | local target_dir = vim.fn.fnamemodify(base, ':h') 1021 | target_dir = (cwd .. target_dir:gsub('^%.$', '')):gsub('/+$', '') .. '/' 1022 | local ok, fs_entries = pcall(vim.fn.readdir, target_dir) 1023 | if not ok then return {} end 1024 | 1025 | -- List directories and files separately 1026 | local dirs, files = {}, {} 1027 | for _, entry in ipairs(fs_entries) do 1028 | local entry_abs = target_dir .. entry 1029 | local arr = vim.fn.isdirectory(entry_abs) == 1 and dirs or files 1030 | table.insert(arr, entry_abs) 1031 | end 1032 | dirs = vim.tbl_map(function(x) return x .. '/' end, dirs) 1033 | 1034 | -- List ordered directories first followed by ordered files 1035 | local order_ignore_case = function(a, b) return a:lower() < b:lower() end 1036 | table.sort(dirs, order_ignore_case) 1037 | table.sort(files, order_ignore_case) 1038 | 1039 | -- Return candidates relative to command's cwd 1040 | local all = dirs 1041 | vim.list_extend(all, files) 1042 | local res = vim.tbl_map(function(x) return x:sub(cwd_len + 1) end, all) 1043 | return res, 'path' 1044 | end 1045 | 1046 | H.command_complete_pullpush = function(cwd, _, line) 1047 | -- Suggest remotes at `Git push |` and `Git push or|`, otherwise - references 1048 | -- Ignore options when deciding which suggestion to compute 1049 | local _, n_words = line:gsub(' (%-%S+)', ''):gsub('%S+ ', '') 1050 | if n_words <= 2 then return H.git_cli_output({ 'remote' }, cwd), 'remote' end 1051 | return H.git_cli_output({ 'rev-parse', '--symbolic', '--branches', '--tags' }, cwd), 'ref' 1052 | end 1053 | 1054 | H.make_git_cli_complete = function(args, complete_type) 1055 | return function(cwd, _) return H.git_cli_output(args, cwd), complete_type end 1056 | end 1057 | 1058 | -- Cover at least all subcommands listed in `git help` 1059 | --stylua: ignore 1060 | H.command_complete_subcommand_targets = { 1061 | -- clone - no targets 1062 | -- init - no targets 1063 | 1064 | -- Worktree 1065 | add = H.command_complete_path, 1066 | mv = H.command_complete_path, 1067 | restore = H.command_complete_path, 1068 | rm = H.command_complete_path, 1069 | 1070 | -- Examine history 1071 | -- bisect - no targets 1072 | diff = H.command_complete_path, 1073 | grep = H.command_complete_path, 1074 | log = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches', '--tags' }, 'ref'), 1075 | show = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches', '--tags' }, 'ref'), 1076 | -- status - no targets 1077 | 1078 | -- Modify history 1079 | branch = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches' }, 'branch'), 1080 | commit = H.command_complete_path, 1081 | merge = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches' }, 'branch'), 1082 | rebase = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches' }, 'branch'), 1083 | reset = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches', '--tags' }, 'ref'), 1084 | switch = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches' }, 'branch'), 1085 | tag = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--tags' }, 'tag'), 1086 | 1087 | -- Collaborate 1088 | fetch = H.make_git_cli_complete({ 'remote' }, 'remote'), 1089 | push = H.command_complete_pullpush, 1090 | pull = H.command_complete_pullpush, 1091 | 1092 | -- Miscellaneous 1093 | checkout = H.make_git_cli_complete({ 'rev-parse', '--symbolic', '--branches', '--tags', '--remotes' }, 'checkout'), 1094 | config = H.make_git_cli_complete({ 'help', '--config-for-completion' }, 'config'), 1095 | help = function() 1096 | local res = { 'git', 'everyday' } 1097 | vim.list_extend(res, H.git_subcommands.supported) 1098 | return res, 'help' 1099 | end, 1100 | } 1101 | 1102 | H.ensure_mods_is_split = function(mods) 1103 | if not H.mods_is_split(mods) then 1104 | local split_val = H.normalize_split_opt(MiniGit.config.command.split, '`config.command.split`') 1105 | mods = split_val .. ' ' .. mods 1106 | end 1107 | return mods 1108 | end 1109 | 1110 | -- NOTE: `mods` is already expanded, so this also covers abbreviated mods 1111 | H.mods_is_split = function(mods) return mods:find('vertical') or mods:find('horizontal') or mods:find('tab') end 1112 | 1113 | -- Show stdout ---------------------------------------------------------------- 1114 | H.show_in_split = function(mods, lines, subcmd, name) 1115 | -- Create a target window split 1116 | mods = H.ensure_mods_is_split(mods) 1117 | local win_source = vim.api.nvim_get_current_win() 1118 | vim.cmd(mods .. ' split') 1119 | local win_stdout = vim.api.nvim_get_current_win() 1120 | 1121 | -- Prepare buffer 1122 | local buf_id = vim.api.nvim_create_buf(false, true) 1123 | H.set_buf_name(buf_id, name) 1124 | vim.api.nvim_buf_set_lines(buf_id, 0, -1, false, lines) 1125 | 1126 | vim.api.nvim_set_current_buf(buf_id) 1127 | H.define_minigit_window() 1128 | 1129 | -- NOTE: set filetype when buffer is in window to allow setting window-local 1130 | -- options in autocommands for `FileType` events 1131 | local filetype 1132 | if subcmd == 'diff' then filetype = 'diff' end 1133 | if subcmd == 'log' or subcmd == 'blame' then filetype = 'git' end 1134 | if subcmd == 'show' then 1135 | -- Try detecting 'git' filetype by content first, as filetype detection can 1136 | -- rely on the buffer name (i.e. command) having proper extension. It isn't 1137 | -- good for cases like `:Git show HEAD file.lua` (which should be 'git'). 1138 | local l = lines[1] 1139 | local is_diff = l:find(string.rep('%x', 40)) or l:find('ref:') 1140 | filetype = is_diff and 'git' or vim.filetype.match({ buf = buf_id }) 1141 | end 1142 | 1143 | local has_filetype = not (filetype == nil or filetype == '') 1144 | if has_filetype then vim.bo[buf_id].filetype = filetype end 1145 | 1146 | -- Completely unfold for no filetype output (like `:Git help`) 1147 | if not has_filetype then vim.wo[win_stdout].foldlevel = 999 end 1148 | 1149 | return win_source, win_stdout 1150 | end 1151 | 1152 | H.define_minigit_window = function(cleanup) 1153 | local buf_id, win_id = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() 1154 | vim.bo.swapfile, vim.bo.buflisted = false, false 1155 | 1156 | -- Define action to finish editing Git related file 1157 | local finish_au_id 1158 | local finish = function(data) 1159 | local should_close = data.buf == buf_id or (data.event == 'WinClosed' and tonumber(data.match) == win_id) 1160 | if not should_close then return end 1161 | 1162 | pcall(vim.api.nvim_del_autocmd, finish_au_id) 1163 | pcall(vim.api.nvim_win_close, win_id, true) 1164 | vim.schedule(function() pcall(vim.api.nvim_buf_delete, buf_id, { force = true }) end) 1165 | 1166 | if vim.is_callable(cleanup) then vim.schedule(cleanup) end 1167 | end 1168 | -- - Use `nested` to allow other events (`WinEnter` for 'mini.statusline') 1169 | local events = { 'WinClosed', 'BufDelete', 'BufWipeout', 'VimLeave' } 1170 | local opts = { nested = true, callback = finish, desc = 'Cleanup window and buffer' } 1171 | finish_au_id = vim.api.nvim_create_autocmd(events, opts) 1172 | end 1173 | 1174 | H.git_cli_output = function(args, cwd, env) 1175 | if cwd ~= nil and (vim.fn.isdirectory(cwd) ~= 1 or cwd == '') then return {} end 1176 | local command = { MiniGit.config.job.git_executable, '--no-pager', unpack(args) } 1177 | local res = H.cli_run(command, cwd, nil, { env = env }).out 1178 | if res == '' then return {} end 1179 | return vim.split(res, '\n') 1180 | end 1181 | 1182 | -- Validators ----------------------------------------------------------------- 1183 | H.validate_buf_id = function(x) 1184 | if x == nil or x == 0 then return vim.api.nvim_get_current_buf() end 1185 | if not (type(x) == 'number' and vim.api.nvim_buf_is_valid(x)) then 1186 | H.error('`buf_id` should be `nil` or valid buffer id.') 1187 | end 1188 | return x 1189 | end 1190 | 1191 | H.normalize_split_opt = function(x, x_name) 1192 | if x == 'auto' then 1193 | -- Show in same tabpage if only minigit buffers visible. Otherwise in new. 1194 | for _, win_id in ipairs(vim.api.nvim_tabpage_list_wins(0)) do 1195 | local win_buf_id = vim.api.nvim_win_get_buf(win_id) 1196 | local win_buf_name = vim.api.nvim_buf_get_name(win_buf_id) 1197 | local is_minigit_win = win_buf_name:find('^minigit://%d+/') ~= nil 1198 | local is_normal_win = vim.api.nvim_win_get_config(win_id).relative == '' 1199 | if not is_minigit_win and is_normal_win then return 'tab' end 1200 | end 1201 | return 'vertical' 1202 | end 1203 | if x == 'horizontal' or x == 'vertical' or x == 'tab' then return x end 1204 | H.error('`' .. x_name .. '` should be one of "auto", "horizontal", "vertical", "tab"') 1205 | end 1206 | 1207 | H.normalize_range_lines = function(line_start, line_end) 1208 | if line_start == nil and line_end == nil then 1209 | line_start = vim.fn.line('.') 1210 | local is_visual = vim.tbl_contains({ 'v', 'V', '\22' }, vim.fn.mode()) 1211 | line_end = is_visual and vim.fn.line('v') or vim.fn.line('.') 1212 | line_start, line_end = math.min(line_start, line_end), math.max(line_start, line_end) 1213 | end 1214 | 1215 | if not (type(line_start) == 'number' and type(line_end) == 'number' and line_start <= line_end) then 1216 | H.error('`line_start` and `line_end` should be non-decreasing numbers.') 1217 | end 1218 | return line_start, line_end 1219 | end 1220 | 1221 | -- Enabling ------------------------------------------------------------------- 1222 | H.is_buf_enabled = function(buf_id) return H.cache[buf_id] ~= nil and vim.api.nvim_buf_is_valid(buf_id) end 1223 | 1224 | H.setup_buf_behavior = function(buf_id) 1225 | local augroup = vim.api.nvim_create_augroup('MiniGitBuffer' .. buf_id, { clear = true }) 1226 | H.cache[buf_id].augroup = augroup 1227 | 1228 | vim.api.nvim_buf_attach(buf_id, false, { 1229 | -- Called when buffer content is changed outside of current session 1230 | -- Needed as otherwise `on_detach()` is called without later auto enabling 1231 | on_reload = function() 1232 | local buf_cache = H.cache[buf_id] 1233 | if buf_cache == nil or buf_cache.root == nil then return end 1234 | -- Don't upate repo/root as it is tracked in 'BufFilePost' autocommand 1235 | H.update_git_head(buf_cache.root, { buf_id }) 1236 | H.update_git_in_progress(buf_cache.repo, { buf_id }) 1237 | -- Don't upate status as it is tracked in file watcher 1238 | end, 1239 | 1240 | -- Called when buffer is unloaded from memory (`:h nvim_buf_detach_event`), 1241 | -- **including** `:edit` command. Together with auto enabling it makes 1242 | -- `:edit` command serve as "restart". 1243 | on_detach = function() MiniGit.disable(buf_id) end, 1244 | }) 1245 | 1246 | local reset_if_enabled = vim.schedule_wrap(function(data) 1247 | if not H.is_buf_enabled(data.buf) then return end 1248 | MiniGit.disable(data.buf) 1249 | MiniGit.enable(data.buf) 1250 | end) 1251 | local bufrename_opts = { group = augroup, buffer = buf_id, callback = reset_if_enabled, desc = 'Reset on rename' } 1252 | -- NOTE: `BufFilePost` does not look like a proper event, but it (yet) works 1253 | vim.api.nvim_create_autocmd('BufFilePost', bufrename_opts) 1254 | 1255 | local buf_disable = function() MiniGit.disable(buf_id) end 1256 | local bufdelete_opts = { group = augroup, buffer = buf_id, callback = buf_disable, desc = 'Disable on delete' } 1257 | vim.api.nvim_create_autocmd('BufDelete', bufdelete_opts) 1258 | end 1259 | 1260 | -- Tracking ------------------------------------------------------------------- 1261 | H.start_tracking = function(buf_id, path) 1262 | local command = H.git_cmd({ 'rev-parse', '--path-format=absolute', '--git-dir', '--show-toplevel' }) 1263 | 1264 | -- If path is not in Git, disable buffer but make sure that it will not try 1265 | -- to re-attach until buffer is properly disabled 1266 | local on_not_in_git = function() 1267 | if H.is_buf_enabled(buf_id) then MiniGit.disable(buf_id) end 1268 | H.cache[buf_id] = {} 1269 | end 1270 | 1271 | local on_done = vim.schedule_wrap(function(code, out, err) 1272 | -- Watch git directory only if there was no error retrieving path to it 1273 | if code ~= 0 then return on_not_in_git() end 1274 | H.cli_err_notify(code, out, err) 1275 | 1276 | -- Update buf data 1277 | local repo, root = string.match(out, '^(.-)\n(.*)$') 1278 | if repo == nil or root == nil then return H.notify('No initial data for buffer ' .. buf_id, 'WARN') end 1279 | H.update_buf_data(buf_id, { repo = repo, root = root }) 1280 | 1281 | -- Set up repo watching to react to Git index changes 1282 | H.setup_repo_watch(buf_id, repo) 1283 | 1284 | -- Set up worktree watching to react to file changes 1285 | H.setup_path_watch(buf_id) 1286 | 1287 | -- Immediately update buffer tracking data 1288 | H.update_git_head(root, { buf_id }) 1289 | H.update_git_in_progress(repo, { buf_id }) 1290 | H.update_git_status(root, { buf_id }) 1291 | end) 1292 | 1293 | H.cli_run(command, vim.fn.fnamemodify(path, ':h'), on_done) 1294 | end 1295 | 1296 | H.setup_repo_watch = function(buf_id, repo) 1297 | local repo_cache = H.repos[repo] or {} 1298 | 1299 | -- Ensure repo is watched 1300 | local is_set_up = repo_cache.fs_event ~= nil and repo_cache.fs_event:is_active() 1301 | if not is_set_up then 1302 | H.teardown_repo_watch(repo) 1303 | local fs_event, timer = vim.loop.new_fs_event(), vim.loop.new_timer() 1304 | 1305 | local on_change = vim.schedule_wrap(function() H.on_repo_change(repo) end) 1306 | local watch = function(_, filename, _) 1307 | -- Ignore temporary changes 1308 | if vim.endswith(filename, 'lock') then return end 1309 | 1310 | -- Debounce to not overload during incremental staging (like in script) 1311 | timer:stop() 1312 | timer:start(50, 0, on_change) 1313 | end 1314 | -- Watch only '.git' dir (non-recursively), as this seems to be both enough 1315 | -- and not supported by libuv (`recursive` flag does nothing, 1316 | -- see https://github.com/libuv/libuv/issues/1778) 1317 | fs_event:start(repo, {}, watch) 1318 | 1319 | repo_cache.fs_event, repo_cache.timer = fs_event, timer 1320 | H.repos[repo] = repo_cache 1321 | end 1322 | 1323 | -- Register buffer to be updated on repo change 1324 | local repo_buffers = repo_cache.buffers or {} 1325 | repo_buffers[buf_id] = true 1326 | repo_cache.buffers = repo_buffers 1327 | end 1328 | 1329 | H.teardown_repo_watch = function(repo) 1330 | if H.repos[repo] == nil then return end 1331 | pcall(vim.loop.fs_event_stop, H.repos[repo].fs_event) 1332 | pcall(vim.loop.timer_stop, H.repos[repo].timer) 1333 | end 1334 | 1335 | H.setup_path_watch = function(buf_id, repo) 1336 | if not H.is_buf_enabled(buf_id) then return end 1337 | 1338 | local on_file_change = function(data) H.update_git_status(H.cache[buf_id].root, { buf_id }) end 1339 | local opts = 1340 | { desc = 'Update Git status', group = H.cache[buf_id].augroup, buffer = buf_id, callback = on_file_change } 1341 | vim.api.nvim_create_autocmd({ 'BufWritePost', 'FileChangedShellPost' }, opts) 1342 | end 1343 | 1344 | H.on_repo_change = function(repo) 1345 | if H.repos[repo] == nil then return end 1346 | 1347 | -- Collect repo's worktrees with their buffers while doing cleanup 1348 | local repo_bufs, root_bufs = H.repos[repo].buffers, {} 1349 | for buf_id, _ in pairs(repo_bufs) do 1350 | if H.is_buf_enabled(buf_id) then 1351 | local root = H.cache[buf_id].root 1352 | local bufs = root_bufs[root] or {} 1353 | table.insert(bufs, buf_id) 1354 | root_bufs[root] = bufs 1355 | else 1356 | repo_bufs[buf_id] = nil 1357 | MiniGit.disable(buf_id) 1358 | end 1359 | end 1360 | 1361 | -- Update Git data 1362 | H.update_git_in_progress(repo, vim.tbl_keys(repo_bufs)) 1363 | for root, bufs in pairs(root_bufs) do 1364 | H.update_git_head(root, bufs) 1365 | -- Status could have also changed as it depends on the index 1366 | H.update_git_status(root, bufs) 1367 | end 1368 | end 1369 | 1370 | H.update_git_head = function(root, bufs) 1371 | local command = H.git_cmd({ 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD' }) 1372 | 1373 | local on_done = vim.schedule_wrap(function(code, out, err) 1374 | -- Ensure proper data 1375 | if code ~= 0 then return end 1376 | H.cli_err_notify(code, out, err) 1377 | 1378 | local head, head_name = string.match(out, '^(.-)\n(.*)$') 1379 | if head == nil or head_name == nil then 1380 | return H.notify('Could not parse HEAD data for root ' .. root .. '\n' .. out, 'WARN') 1381 | end 1382 | 1383 | -- Update data for all buffers from target `root` 1384 | local new_data = { head = head, head_name = head_name } 1385 | for _, buf_id in ipairs(bufs) do 1386 | H.update_buf_data(buf_id, new_data) 1387 | end 1388 | 1389 | -- Redraw statusline to have possible statusline component up to date 1390 | H.redrawstatus() 1391 | end) 1392 | 1393 | H.cli_run(command, root, on_done) 1394 | end 1395 | 1396 | H.update_git_in_progress = function(repo, bufs) 1397 | -- Get data about what process is in progress 1398 | local in_progress = {} 1399 | if H.is_fs_present(repo .. '/BISECT_LOG') then table.insert(in_progress, 'bisect') end 1400 | if H.is_fs_present(repo .. '/CHERRY_PICK_HEAD') then table.insert(in_progress, 'cherry-pick') end 1401 | if H.is_fs_present(repo .. '/MERGE_HEAD') then table.insert(in_progress, 'merge') end 1402 | if H.is_fs_present(repo .. '/REVERT_HEAD') then table.insert(in_progress, 'revert') end 1403 | if H.is_fs_present(repo .. '/rebase-apply') then table.insert(in_progress, 'apply') end 1404 | if H.is_fs_present(repo .. '/rebase-merge') then table.insert(in_progress, 'rebase') end 1405 | 1406 | -- Update data for all buffers from target `root` 1407 | local new_data = { in_progress = table.concat(in_progress, ',') } 1408 | for _, buf_id in ipairs(bufs) do 1409 | H.update_buf_data(buf_id, new_data) 1410 | end 1411 | 1412 | -- Redraw statusline to have possible statusline component up to date 1413 | H.redrawstatus() 1414 | end 1415 | 1416 | H.update_git_status = function(root, bufs) 1417 | --stylua: ignore 1418 | local command = H.git_cmd({ 1419 | -- NOTE: Use `--no-optional-locks` to reduce conflicts with other Git tasks 1420 | '--no-optional-locks', 'status', 1421 | '--verbose', '--untracked-files=all', '--ignored', '--porcelain', '-z', 1422 | '--', 1423 | }) 1424 | local root_len, path_data = string.len(root), {} 1425 | for _, buf_id in ipairs(bufs) do 1426 | -- Use paths relative to the root as in `git status --porcelain` output 1427 | local rel_path = H.get_buf_realpath(buf_id):sub(root_len + 2) 1428 | table.insert(command, rel_path) 1429 | -- Completely not modified paths should be the only ones missing in the 1430 | -- output. Use this status as default. 1431 | path_data[rel_path] = { status = ' ', buf_id = buf_id } 1432 | end 1433 | 1434 | local on_done = vim.schedule_wrap(function(code, out, err) 1435 | if code ~= 0 then return end 1436 | H.cli_err_notify(code, out, err) 1437 | 1438 | -- Parse CLI output, which is separated by `\0` to not escape "bad" paths 1439 | for _, l in ipairs(vim.split(out, '\0')) do 1440 | local status, rel_path = string.match(l, '^(..) (.*)$') 1441 | if path_data[rel_path] ~= nil then path_data[rel_path].status = status end 1442 | end 1443 | 1444 | -- Update data for all buffers 1445 | for _, data in pairs(path_data) do 1446 | local new_data = { status = data.status } 1447 | H.update_buf_data(data.buf_id, new_data) 1448 | end 1449 | 1450 | -- Redraw statusline to have possible statusline component up to date 1451 | H.redrawstatus() 1452 | end) 1453 | 1454 | H.cli_run(command, root, on_done) 1455 | end 1456 | 1457 | H.update_buf_data = function(buf_id, new_data) 1458 | if not H.is_buf_enabled(buf_id) then return end 1459 | 1460 | local summary = vim.b[buf_id].minigit_summary or {} 1461 | for key, val in pairs(new_data) do 1462 | H.cache[buf_id][key], summary[key] = val, val 1463 | end 1464 | vim.b[buf_id].minigit_summary = summary 1465 | 1466 | -- Format summary string 1467 | local head = summary.head_name or '' 1468 | head = head == 'HEAD' and summary.head:sub(1, 7) or head 1469 | 1470 | local in_progress = summary.in_progress or '' 1471 | if in_progress ~= '' then head = head .. '|' .. in_progress end 1472 | 1473 | local summary_string = head 1474 | local status = summary.status or '' 1475 | if status ~= ' ' and status ~= '' then summary_string = string.format('%s (%s)', head, status) end 1476 | vim.b[buf_id].minigit_summary_string = summary_string 1477 | 1478 | -- Trigger dedicated event with target current buffer (for proper `data.buf`) 1479 | vim.api.nvim_buf_call(buf_id, function() H.trigger_event('MiniGitUpdated') end) 1480 | end 1481 | 1482 | -- History navigation --------------------------------------------------------- 1483 | -- Assuming buffer contains unified combined diff (with "commit" header), 1484 | -- compute path, line number, and commit of both "before" and "after" files. 1485 | -- Allow cursor to be between "--- a/xxx" line and last line of a hunk. 1486 | H.diff_pos_to_source = function() 1487 | local lines, lnum = vim.api.nvim_buf_get_lines(0, 0, -1, false), vim.fn.line('.') 1488 | 1489 | local res = { init_prefix = lines[lnum]:sub(1, 1) } 1490 | local paths_lnum = H.diff_parse_paths(res, lines, lnum) 1491 | local hunk_lnum = H.diff_parse_hunk(res, lines, lnum) 1492 | local commit_lnum = H.diff_parse_commits(res, lines, lnum) 1493 | 1494 | -- Try fall back to inferring target commits from 'mini.git' buffer name 1495 | if res.commit_before == nil or res.commit_after == nil then H.diff_parse_bufname(res) end 1496 | 1497 | local all_present = (res.lnum_before and res.path_before and res.commit_before) 1498 | or (res.lnum_after and res.path_after and res.commit_after) 1499 | local is_in_order = commit_lnum <= paths_lnum and paths_lnum <= hunk_lnum 1500 | if not (all_present and is_in_order) then return nil end 1501 | 1502 | return res 1503 | end 1504 | 1505 | H.diff_parse_paths = function(out, lines, lnum) 1506 | -- NOTE: with `diff.mnemonicPrefix=true` source and destination prefixes can 1507 | -- be not only `a`/`b`, but other characters or none (if added/deleted file) 1508 | local pattern_before, pattern_after = '^%-%-%- ([acio]?)/(.*)$', '^%+%+%+ ([biw]?)/(.*)$' 1509 | 1510 | -- Allow placing cursor directly on path defining lines 1511 | local cur_line = lines[lnum] 1512 | local prefix_before, path_before = string.match(cur_line, pattern_before) 1513 | local prefix_after, path_after = string.match(cur_line, pattern_after) 1514 | if path_before ~= nil then 1515 | out.path_before = path_before 1516 | prefix_after, out.path_after = string.match(lines[lnum + 1] or '', pattern_after) 1517 | out.lnum_before, out.lnum_after = 1, 1 1518 | elseif path_after ~= nil then 1519 | prefix_before, out.path_before = string.match(lines[lnum - 1] or '', pattern_before) 1520 | out.path_after = path_after 1521 | out.lnum_before, out.lnum_after = 1, 1 1522 | else 1523 | -- Iterate lines upward to find path patterns 1524 | while out.path_after == nil and lnum > 0 do 1525 | prefix_after, out.path_after = string.match(lines[lnum] or '', pattern_after) 1526 | lnum = lnum - 1 1527 | end 1528 | prefix_before, out.path_before = string.match(lines[lnum] or '', pattern_before) 1529 | end 1530 | 1531 | -- - Don't treat '--- /dev/null' and '+++ /dev/null' matches as paths 1532 | -- Need to check prefix to work in cases like '--- a/dev/null' 1533 | if prefix_before == '' then out.path_before = nil end 1534 | if prefix_after == '' then out.path_after = nil end 1535 | 1536 | return lnum 1537 | end 1538 | 1539 | H.diff_parse_hunk = function(out, lines, lnum) 1540 | if out.lnum_after ~= nil then return lnum end 1541 | 1542 | local offsets = { [' '] = 0, ['-'] = 0, ['+'] = 0 } 1543 | while lnum > 0 do 1544 | local prefix = lines[lnum]:sub(1, 1) 1545 | if not (prefix == ' ' or prefix == '-' or prefix == '+') then break end 1546 | offsets[prefix] = offsets[prefix] + 1 1547 | lnum = lnum - 1 1548 | end 1549 | 1550 | local hunk_start_before, hunk_start_after = string.match(lines[lnum] or '', '^@@ %-(%d+),?%d* %+(%d+),?%d* @@') 1551 | if hunk_start_before ~= nil then 1552 | out.lnum_before = math.max(1, tonumber(hunk_start_before) + offsets[' '] + offsets['-'] - 1) 1553 | out.lnum_after = math.max(1, tonumber(hunk_start_after) + offsets[' '] + offsets['+'] - 1) 1554 | end 1555 | return lnum 1556 | end 1557 | 1558 | H.diff_parse_commits = function(out, lines, lnum) 1559 | while out.commit_after == nil and lnum > 0 do 1560 | out.commit_after = string.match(lines[lnum], '^commit (%x+)$') 1561 | lnum = lnum - 1 1562 | end 1563 | if out.commit_after ~= nil then out.commit_before = out.commit_after .. '~' end 1564 | return lnum + 1 1565 | end 1566 | 1567 | H.diff_parse_bufname = function(out) 1568 | local buf_name = vim.api.nvim_buf_get_name(0) 1569 | local diff_command = string.match(buf_name, '^minigit://%d+/.* diff ?(.*)$') 1570 | if diff_command == nil then return end 1571 | 1572 | -- Work with output of common `:Git diff` commands 1573 | diff_command = vim.trim(diff_command) 1574 | -- `Git diff` - compares index and work tree 1575 | if diff_command == '' then 1576 | out.commit_before, out.commit_after = ':0', true 1577 | end 1578 | -- `Git diff --cached` - compares HEAD and index 1579 | if diff_command == '--cached' then 1580 | out.commit_before, out.commit_after = 'HEAD', ':0' 1581 | end 1582 | -- `Git diff HEAD` - compares commit and work tree 1583 | if diff_command:find('^[^-]%S*$') ~= nil then 1584 | out.commit_before, out.commit_after = diff_command, true 1585 | end 1586 | end 1587 | 1588 | H.parse_diff_source_buf_name = function(buf_name) return string.match(buf_name, '^minigit://%d+/.*show (%x+~?):(.*)$') end 1589 | 1590 | H.deps_pos_to_source = function() 1591 | local lines = vim.api.nvim_buf_get_lines(0, 0, vim.fn.line('.'), false) 1592 | -- Do nothing if on the title (otherwise it operates on previous plugin info) 1593 | if lines[#lines]:find('^[%+%-!]') ~= nil then return end 1594 | 1595 | -- Locate lines with commit and repo path data 1596 | local commit, commit_lnum = nil, #lines 1597 | while commit == nil and commit_lnum >= 1 do 1598 | local l = lines[commit_lnum] 1599 | commit = l:match('^[><] (%x%x%x%x%x%x%x%x*) |') or l:match('^State[^:]*: %s*(%x+)') 1600 | commit_lnum = commit_lnum - 1 1601 | end 1602 | 1603 | local cwd, cwd_lnum = nil, #lines 1604 | while cwd == nil and cwd_lnum >= 1 do 1605 | cwd, cwd_lnum = lines[cwd_lnum]:match('^Path: %s*(%S+)$'), cwd_lnum - 1 1606 | end 1607 | 1608 | -- Do nothing if something is not found or path corresponds to next repo 1609 | if commit == nil or cwd == nil or commit_lnum <= cwd_lnum then return end 1610 | return commit, cwd 1611 | end 1612 | 1613 | -- Folding -------------------------------------------------------------------- 1614 | H.is_hunk_header = function(lnum) return vim.fn.getline(lnum):find('^@@.*@@') ~= nil end 1615 | 1616 | H.is_log_entry_header = function(lnum) return vim.fn.getline(lnum):find('^commit ') ~= nil end 1617 | 1618 | H.is_file_entry_header = function(lnum) return vim.fn.getline(lnum):find('^diff %-%-git') ~= nil end 1619 | 1620 | -- CLI ------------------------------------------------------------------------ 1621 | H.git_cmd = function(args) 1622 | -- Use '-c gc.auto=0' to disable `stderr` "Auto packing..." messages 1623 | return { MiniGit.config.job.git_executable, '-c', 'gc.auto=0', unpack(args) } 1624 | end 1625 | 1626 | H.make_spawn_env = function(env_vars) 1627 | -- Setup all environment variables (`vim.loop.spawn()` by default has none) 1628 | local environ = vim.tbl_deep_extend('force', vim.loop.os_environ(), env_vars) 1629 | local res = {} 1630 | for k, v in pairs(environ) do 1631 | table.insert(res, string.format('%s=%s', k, tostring(v))) 1632 | end 1633 | return res 1634 | end 1635 | 1636 | H.cli_run = function(command, cwd, on_done, opts) 1637 | local spawn_opts = opts or {} 1638 | local executable, args = command[1], vim.list_slice(command, 2, #command) 1639 | local process, stdout, stderr = nil, vim.loop.new_pipe(), vim.loop.new_pipe() 1640 | spawn_opts.args, spawn_opts.cwd, spawn_opts.stdio = args, cwd or vim.fn.getcwd(), { nil, stdout, stderr } 1641 | 1642 | -- Allow `on_done = nil` to mean synchronous execution 1643 | local is_sync, res = false, nil 1644 | if on_done == nil then 1645 | is_sync = true 1646 | on_done = function(code, out, err) res = { code = code, out = out, err = err } end 1647 | end 1648 | 1649 | local out, err, is_done = {}, {}, false 1650 | local on_exit = function(code) 1651 | -- Ensure calling this only once 1652 | if is_done then return end 1653 | is_done = true 1654 | 1655 | if process:is_closing() then return end 1656 | process:close() 1657 | 1658 | -- Convert to strings appropriate for notifications 1659 | out = H.cli_stream_tostring(out) 1660 | err = H.cli_stream_tostring(err):gsub('\r+', '\n'):gsub('\n%s+\n', '\n\n') 1661 | on_done(code, out, err) 1662 | end 1663 | 1664 | process = vim.loop.spawn(executable, spawn_opts, on_exit) 1665 | H.cli_read_stream(stdout, out) 1666 | H.cli_read_stream(stderr, err) 1667 | vim.defer_fn(function() 1668 | if H.skip_timeout or not process:is_active() then return end 1669 | H.notify('PROCESS REACHED TIMEOUT', 'WARN') 1670 | on_exit(1) 1671 | end, MiniGit.config.job.timeout) 1672 | 1673 | if is_sync then vim.wait(MiniGit.config.job.timeout + 10, function() return is_done end, 1) end 1674 | return res 1675 | end 1676 | 1677 | H.cli_read_stream = function(stream, feed) 1678 | local callback = function(err, data) 1679 | if err then return table.insert(feed, 1, 'ERROR: ' .. err) end 1680 | if data ~= nil then return table.insert(feed, data) end 1681 | stream:close() 1682 | end 1683 | stream:read_start(callback) 1684 | end 1685 | 1686 | H.cli_stream_tostring = function(stream) return (table.concat(stream):gsub('\n+$', '')) end 1687 | 1688 | H.cli_err_notify = function(code, out, err) 1689 | local should_stop = code ~= 0 1690 | if should_stop then H.notify(err .. (out == '' and '' or ('\n' .. out)), 'ERROR') end 1691 | if not should_stop and err ~= '' then H.notify(err, 'WARN') end 1692 | return should_stop 1693 | end 1694 | 1695 | H.cli_escape = function(x) return (string.gsub(x, '([ \\])', '\\%1')) end 1696 | 1697 | -- Utilities ------------------------------------------------------------------ 1698 | H.error = function(msg) error('(mini.git) ' .. msg, 0) end 1699 | 1700 | H.check_type = function(name, val, ref, allow_nil) 1701 | if type(val) == ref or (ref == 'callable' and vim.is_callable(val)) or (allow_nil and val == nil) then return end 1702 | H.error(string.format('`%s` should be %s, not %s', name, ref, type(val))) 1703 | end 1704 | 1705 | H.set_buf_name = function(buf_id, name) vim.api.nvim_buf_set_name(buf_id, 'minigit://' .. buf_id .. '/' .. name) end 1706 | 1707 | H.notify = function(msg, level_name) vim.notify('(mini.git) ' .. msg, vim.log.levels[level_name]) end 1708 | 1709 | H.trigger_event = function(event_name, data) vim.api.nvim_exec_autocmds('User', { pattern = event_name, data = data }) end 1710 | 1711 | H.is_fs_present = function(path) return vim.loop.fs_stat(path) ~= nil end 1712 | 1713 | H.expandcmd = function(x) 1714 | if x == '' then return vim.fn.getcwd() end 1715 | local ok, res = pcall(vim.fn.expandcmd, x) 1716 | return ok and res or x 1717 | end 1718 | 1719 | -- TODO: Remove after compatibility with Neovim=0.9 is dropped 1720 | H.islist = vim.fn.has('nvim-0.10') == 1 and vim.islist or vim.tbl_islist 1721 | 1722 | -- Try getting buffer's full real path (after resolving symlinks) 1723 | H.get_buf_realpath = function(buf_id) return vim.loop.fs_realpath(vim.api.nvim_buf_get_name(buf_id)) or '' end 1724 | 1725 | H.redrawstatus = function() vim.cmd('redrawstatus') end 1726 | if vim.api.nvim__redraw ~= nil then H.redrawstatus = function() vim.api.nvim__redraw({ statusline = true }) end end 1727 | 1728 | return MiniGit 1729 | --------------------------------------------------------------------------------