├── 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 |
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 |
--------------------------------------------------------------------------------