8000 mini.nvim/lua/mini/git.lua at main · PeterCardenas/mini.nvim · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
1728 lines (1499 loc) · 71 KB

git.lua

File metadata and controls

1728 lines (1499 loc) · 71 KB
--- *mini.git* Git integration
---
--- MIT License Copyright (c) 2024 Evgeni Chasnovski
--- Features:
---
--- - Automated tracking of Git related data: root path, status, HEAD, etc.
--- Exposes buffer-local variables for convenient use in statusline.
--- See |MiniGit.enable()| and |MiniGit.get_buf_data()| for more information.
---
--- - |: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.).
---
--- - Helper functions to inspect Git history:
--- - |MiniGit.show_range_history()| shows how certain line range evolved.
--- - |MiniGit.show_diff_source()| shows file state as it was at diff entry.
--- - |MiniGit.show_at_cursor()| shows Git related data depending on context.
---
--- What it doesn't do:
---
--- - 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| or fully featured Git client.
---
--- Sources with more details:
--- - |:Git|
--- - |MiniGit-examples|
--- - |MiniGit.enable()|
--- - |MiniGit.get_buf_data()|
---
--- # Setup ~
---
--- This module needs a setup with `require('mini.git').setup({})` (replace `{}` with
--- your `config` table). It will create global Lua table `MiniGit` which you can use
--- for scripting or manually (with `:lua MiniGit.*`).
---
--- See |MiniGit.config| for `config` structure and default values.
---
--- # Comparisons ~
---
--- - [tpope/vim-fugitive](https://github.com/tpope/vim-fugitive):
--- - Mostly a dedicated Git client, while this module is not (by design).
--- - Provides buffer-local Git data only through fixed statusline component,
--- while this module has richer data in the form of a Lua table.
--- - Both provide |:Git| command with 'vim-fugitive' treating some cases
--- extra specially (like `:Git blame`, etc.), while this module mostly
--- treats all cases the same. See |MiniGit-examples| for how they can be
--- manually customized.
--- Also this module provides slightly different (usually richer)
--- completion suggestions.
---
--- - [NeogitOrg/neogit](https://github.com/NeogitOrg/neogit):
--- - Similar to 'tpope/vim-fugitive', but without `:Git` command.
---
--- - [lewis6991/gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim):
--- - Provides buffer-local Git data with emphasis on granular diff status,
--- while this module is more oriented towards repository and file level
--- data (root, HEAD, file status, etc.). Use |mini.diff| for diff tracking.
---
--- # Disabling ~
---
--- To prevent buffer(s) from being tracked, set `vim.g.minigit_disable` (globally)
--- or `vim.b.minigit_disable` (for a buffer) to `true`. Considering high number of
--- different scenarios and customization intentions, writing exact rules for
--- disabling module's functionality is left to user.
--- See |mini.nvim-disabling-recipes| for common recipes.
---@tag MiniGit
--- # Statusline component ~
---
--- Tracked buffer data can be used in statusline via `vim.b.minigit_summary_string`
--- buffer-local variable. It is expected to be used as is. To show another info,
--- tweak buffer-local variable directly inside `MiniGitUpdated` `User` event: >lua
---
--- -- Use only HEAD name as summary string
--- local format_summary = function(data)
--- -- Utilize buffer-local table summary
--- local summary = vim.b[data.buf].minigit_summary
--- vim.b[data.buf].minigit_summary_string = summary.head_name or ''
--- end
---
--- local au_opts = { pattern = 'MiniGitUpdated', callback = format_summary }
--- vim.api.nvim_create_autocmd('User', au_opts)
--- <
--- # Tweaking command output ~
---
--- Buffer output of |:Git| command can be tweaked inside autocommand for
--- `MiniGitCommandSplit` `User` event (see |MiniGit-command-events|).
--- For example, to make `:vertical Git blame -- %` align blame output with the
--- current window state, use the following code: >lua
---
--- local align_blame = function(au_data)
--- if au_data.data.git_subcommand ~= 'blame' then return end
---
--- -- Align blame output with source
--- local win_src = au_data.data.win_source
--- vim.wo.wrap = false
--- vim.fn.winrestview({ topline = vim.fn.line('w0', win_src) })
--- vim.api.nvim_win_set_cursor(0, { vim.fn.line('.', win_src), 0 })
---
--- -- Bind both windows so that they scroll together
--- vim.wo[win_src].scrollbind, vim.wo.scrollbind = true, true
--- end
---
--- local au_opts = { pattern = 'MiniGitCommandSplit', callback = align_blame }
--- vim.api.nvim_create_autocmd('User', au_opts)
--- <
--- # History navigation ~
---
--- Function |MiniGit.show_at_cursor()| is specifically exported to make Git
--- history navigation easier. Here are some different ways it can be used:
---
--- - Call inside buffer for already committed file to show the evolution of
--- the current line (or visually selected range) through history.
--- It is essentially a `:Git log HEAD` with proper `-L` flag.
--- This also works inside output of |MiniGit.show_diff_source()|.
---
--- - Call with cursor on commit hash to inspect that commit in full.
--- This is usually helpful in the output of `:Git log`.
---
--- - Call with cursor inside diff entry to inspect its file in the state how it
--- was at certain commit. By default it shows state after commit, unless cursor
--- is on the "deleted" line (i.e. line starting with "-") in which case
--- state before commit is shown.
---
--- This workflow can be made more interactive when used with mapping, like this: >lua
---
--- local rhs = '<Cmd>lua MiniGit.show_at_cursor()<CR>'
--- vim.keymap.set({ 'n', 'x' }, '<Leader>gs', rhs, { desc = 'Show at cursor' })
--- <
---@tag MiniGit-examples
--- The `:Git` user command runs `git` CLI call with extra integration for currently
--- opened Neovim process:
--- - Command is executed inside repository root of the currently active file
--- (or |current-directory| if file is not tracked by this module).
---
--- - Command output is shown either in dedicated buffer in window split or as
--- notification via |vim.notify()|. Which method is used depends on whether
--- particular Git subcommand is supposed to show data for user to inspect
--- (like `log`, `status`, etc.) or not (like `commit`, `push`, etc.). This is
--- determined automatically based on the data Git itself provides.
--- Split window is made current after command execution.
---
--- Use split-related |:command-modifiers| (|:vertical|, |:horizontal|, or |:tab|)
--- to force output in a particular type of split. Default split direction is
--- controlled by `command.split` in |MiniGit.config|.
---
--- Use |:silent| command modifier to not show any output.
---
--- Errors and warnings are always shown as notifications.
---
--- See |MiniGit-examples| for the example of tweaking command output.
---
--- - Editor for tasks that require interactive user input (like `:Git commit` or
--- `:Git rebase --interactive`) is opened inside current session in a separate
--- split. Make modifications as in regular buffer, |:write| changes followed by
--- |:close| / |:quit| for Git CLI command to resume.
---
--- Examples of usage:
--- - `:Git log --oneline` - show compact log of current repository.
--- - `:vert Git blame -- %` - show latest commits per line in vertical split.
--- - `:Git help rebase` - show help page for `rebase` subcommand.
--- - `:Git -C <cwd> status` - execute `git status` inside |current-directory|.
---
--- There is also a context aware completion which can be invoked with `<Tab>`:
--- - If completed word starts with "-", options for the current Git subcommand
--- are shown. Like completion at `:Git log -` will suggest `-L`, `--oneline`, etc.
--- - If there is an explicit " -- " to the cursor's left, incremental path
--- suggestions will be shown.
--- - If there is no recognized Git subcommand yet, show list of subcommands.
--- Otherwise for some common subcommands list of its targets will be suggested:
--- like for `:Git branch` it will be list of branches, etc.
---
--- Notes:
--- - Paths are always treated as relative to command's execution directory
--- (file's repository root or |current-directory| if absent).
--- - Don't use quotes for entries containing space, escape it with `\` directly.
--- Like `:Git commit -m Hello\ world` and not `:Git commit -m 'Hello world'`
--- (which treats `'Hello` and `world'` as separate arguments).
---
--- # Events ~
--- *MiniGit-command-events*
---
--- There are several `User` events triggered during command execution:
---
--- - `MiniGitCommandDone` - after command is done executing. For Lua callbacks it
--- provides a special `data` table with the following fields:
--- - <cmd_input> `(table)` - structured data about executed command.
--- Has same structure as Lua function input in |nvim_create_user_command()|.
--- - <cwd> `(string)` - directory path inside which Git command was executed.
--- - <exit_code> `(number)` - exit code of CLI process.
--- - <git_command> `(table)` - array with arguments of full executed command.
--- - <git_subcommand> `(string)` - detected Git subcommand (like "log", etc.).
--- - <stderr> `(string)` - `stderr` process output.
--- - <stdout> `(string)` - `stdout` process output.
---
--- - `MiniGitCommandSplit` - after command showed its output in a split. Triggered
--- after `MiniGitCommandDone` and provides similar `data` table with extra fields:
--- - <win_source> `(number)` - window identifier of "source" window (current at
--- the moment before command execution).
--- - <win_stdout> `(number)` - window identifier of command output.
---@tag :Git
---@alias __git_buf_id number Target buffer identifier. Default: 0 for current buffer.
---@alias __git_split_field <split> `(string)` - split direction. One of "horizontal", "vertical",
--- "tab", or "auto" (default). Value "auto" uses |:vertical| if only 'mini.git'
--- buffers are shown in the tabpage and |:tab| otherwise.
---@diagnostic disable:undefined-field
---@diagnostic disable:discard-returns
---@diagnostic disable:unused-local
---@diagnostic disable:cast-local-type
---@diagnostic disable:undefined-doc-name
---@diagnostic disable:luadoc-miss-type-name
-- Module definition ==========================================================
local MiniGit = {}
local H = {}
--- Module setup
---
--- Besides general side effects (see |mini.nvim|), it also:
--- - Sets up auto enabling in every normal buffer for an actual file on disk.
--- - Creates |:Git| command.
---
---@param config table|nil Module config table. See |MiniGit.config|.
---
---@usage >lua
--- require('mini.git').setup() -- use default config
--- -- OR
--- require('mini.git').setup({}) -- replace {} with your config table
--- <
MiniGit.setup = function(config)
-- Export module
_G.MiniGit = MiniGit
-- Setup config
config = H.setup_config(config)
-- Apply config
H.apply_config(config)
-- Ensure proper Git executable
local exec = config.job.git_executable
H.has_git = vim.fn.executable(exec) == 1
if not H.has_git then H.notify('There is no `' .. exec .. '` executable', 'WARN') end
-- Define behavior
H.create_autocommands()
for _, buf_id in ipairs(vim.api.nvim_list_bufs()) do
H.auto_enable({ buf = buf_id })
end
-- Create user commands
H.create_user_commands()
end
--stylua: ignore
--- Defaults ~
---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section)
---@text # Job ~
---
--- `config.job` contains options for customizing CLI executions.
---
--- `job.git_executable` defines a full path to Git executable. Default: "git".
---
--- `job.timeout` is a duration (in ms) from job start until it is forced to stop.
--- Default: 30000.
---
--- # Command ~
---
--- `config.command` contains options for customizing |:Git| command.
---
--- `command.split` defines default split direction for |:Git| command output. Can be
--- one of "horizontal", "vertical", "tab", or "auto". Value "auto" uses |:vertical|
--- if only 'mini.git' buffers are shown in the tabpage and |:tab| otherwise.
--- Default: "auto".
MiniGit.config = {
-- General CLI execution
job = {
-- Path to Git executable
git_executable = 'git',
-- Timeout (in ms) for each job before force quit
timeout = 30000,
},
-- Options for `:Git` command
command = {
-- Default split direction
split = 'auto',
},
}
--minidoc_afterlines_end
--- Show Git related data at cursor
---
--- - If inside |mini.deps| confirmation buffer, show in split relevant commit data.
--- - If there is a commit-like |<cword>|, show it in split.
--- - If possible, show diff source via |MiniGit.show_diff_source()|.
--- - If possible, show range history via |MiniGit.show_range_history()|.
--- - Otherwise throw an error.
---
---@param opts table|nil Options. Possible values:
--- - __git_split_field
--- - Fields appropriate for forwarding to other functions.
MiniGit.show_at_cursor = function(opts)
-- Try showing commit data at cursor
local commit, cwd
if vim.bo.filetype == 'minideps-confirm' then
commit, cwd = H.deps_pos_to_source()
else
local cword = vim.fn.expand('<cword>')
local is_commit = string.find(cword, '^%x%x%x%x%x%x%x+$') ~= nil and string.lower(cword) == cword
commit = is_commit and cword or nil
cwd = is_commit and H.get_git_cwd() or nil
end
if commit ~= nil and cwd ~= nil then
local split = H.normalize_split_opt((opts or {}).split or 'auto', 'opts.split')
local args = { 'show', '--stat', '--patch', commit }
local lines = H.git_cli_output(args, cwd)
if #lines == 0 then return H.notify('Can not show commit ' .. commit .. ' in repo ' .. cwd, 'WARN') end
H.show_in_split(split, lines, 'show', table.concat(args, ' '))
vim.bo.filetype = 'git'
return
end
-- Try showing diff source
if H.diff_pos_to_source() ~= nil then return MiniGit.show_diff_source(opts) end
-- Try showing range history if possible: either in Git repo (tracked or not;
-- after resolving symlinks) or diff source output.
local buf_id, buf_name = vim.api.nvim_get_current_buf(), vim.api.nvim_buf_get_name(0)
local path = vim.loop.fs_realpath(buf_name)
local path_dir = path == nil and '' or vim.fn.fnamemodify(path, ':h')
local is_in_git = H.is_buf_enabled(buf_id) or #H.git_cli_output({ 'rev-parse', '--show-toplevel' }, path_dir) > 0
local is_diff_source_output = H.parse_diff_source_buf_name(buf_name) ~= nil
if is_in_git or is_diff_source_output then return MiniGit.show_range_history(opts) end
H.notify('Nothing Git-related to show at cursor', 'WARN')
end
--- Show diff source
---
--- When buffer contains text formatted as unified patch (like after
--- `:Git log --patch`, `:Git diff`, or |MiniGit.show_range_history()|),
--- show state of the file at the particular state. Target commit/state, path,
--- and line number are deduced from cursor position.
---
--- Notes:
--- - Needs |current-directory| to be the Git root for relative paths to work.
--- - Needs cursor to be inside hunk lines or on "---" / "+++" lines with paths.
--- - Only basic forms of `:Git diff` output is supported: `:Git diff`,
--- `:Git diff --cached`, and `:Git diff <commit>`.
---
---@param opts table|nil Options. Possible values:
--- - __git_split_field
--- - <target> `(string)` - which file state to show. One of "before", "after",
--- "both" (both states in vertical split), "auto" (default). Value "auto"
--- shows "before" state if cursor line starts with "-", otherwise - "after".
MiniGit.show_diff_source = function(opts)
opts = vim.tbl_deep_extend('force', { split = 'auto', target = 'auto' }, opts or {})
local split = H.normalize_split_opt(opts.split, 'opts.split')
local target = opts.target
if not (target == 'auto' or target == 'before' or target == 'after' or target == 'both') then
H.error('`opts.target` should be one of "auto", "before", "after", "both".')
end
local src = H.diff_pos_to_source()
if src == nil then
return H.notify('Could not find diff source. Ensure that cursor is inside a valid diff lines of git log.', 'WARN')
end
if target == 'auto' then target = src.init_prefix == '-' and 'before' or 'after' end
local cwd = H.get_git_cwd()
local show = function(commit, path, mods)
local is_worktree, args, lines = commit == true, nil, nil
if is_worktree then
args, lines = { 'edit', vim.fn.fnameescape(path) }, vim.fn.readfile(path)
else
args = { 'show', commit .. ':' .. path }
lines = H.git_cli_output(args, cwd)
end
if #lines == 0 and not is_worktree then
return H.notify('Can not show ' .. path .. ' at commit ' .. commit, 'WARN')
end
H.show_in_split(mods, lines, 'show', table.concat(args, ' '))
end
local has_before_shown = false
if target ~= 'after' then
if src.path_before == nil then
H.notify('No "before" as file was created', 'WARN')
else
show(src.commit_before, src.path_before, split)
vim.api.nvim_win_set_cursor(0, { src.lnum_before, 0 })
has_before_shown = true
end
end
if target ~= 'before' then
if src.path_after == nil then
H.notify('No "after" as file was deleted', 'WARN')
else
local mods_after = has_before_shown and 'belowright vertical' or split
show(src.commit_after, src.path_after, mods_after)
vim.api.nvim_win_set_cursor(0, { src.lnum_after, 0 })
end
end
end
--- Show range history
---
--- Compute and show in split data about how particular line range in current
--- buffer evolved through Git history. Essentially a `git log` with `-L` flag.
---
--- Notes:
--- - Works well with |MiniGit.diff_foldexpr()|.
--- - Does not work if there are uncommited changes, as there is no easy way to
--- compute effective range line numbers.
---
---@param opts table|nil Options. Possible fields:
--- - <line_start> `(number)` - range start line.
--- - <line_end> `(number)` - range end line.
--- If both <line_start> and <line_end> are not supplied, they default to
--- current line in Normal mode and visual selection in Visual mode.
--- - <log_args> `(table)` - array of options to append to `git log` call.
--- - __git_split_field
MiniGit.show_range_history = function(opts)
local default_opts = { line_start = nil, line_end = nil, log_args = nil, split = 'auto' }
opts = vim.tbl_deep_extend('force', default_opts, opts or {})
local line_start, line_end = H.normalize_range_lines(opts.line_start, opts.line_end)
local log_args = opts.log_args or {}
if not H.islist(log_args) then H.error('`opts.log_args` should be an array.') end
local split = H.normalize_split_opt(opts.split, 'opts.split')
-- Construct `:Git log` command that works both with regular files and
-- buffers from `show_diff_source()`
local buf_name, cwd = vim.api.nvim_buf_get_name(0), H.get_git_cwd()
local commit, rel_path = H.parse_diff_source_buf_name(buf_name)
if commit == nil then
commit = 'HEAD'
local cwd_pattern = '^' .. vim.pesc(cwd:gsub('\\', '/')) .. '/'
rel_path = H.get_buf_realpath(0):gsub('\\', '/'):gsub(cwd_pattern, '')
end
-- Ensure no uncommitted changes as they might result into improper `-L` arg
local diff = commit == 'HEAD' and H.git_cli_output({ 'diff', '-U0', 'HEAD', '--', rel_path }, cwd) or {}
if #diff ~= 0 then
return H.notify('Current file has uncommitted lines. Commit or stash before exploring history.', 'WARN')
end
-- Show log in split
local range_flag = string.format('-L%d,%d:%s', line_start, line_end, rel_path)
local args = { 'log', range_flag, commit, unpack(log_args) }
local history = H.git_cli_output(args, cwd)
if #history == 0 then return H.notify('Could not get range history', 'WARN') end
H.show_in_split(split, history, 'log', table.concat(args, ' '))
end
--- Fold expression for Git logs
---
--- Folds contents of hunks, file patches, and log entries in unified diff.
--- Useful for filetypes "diff" (like after `:Git diff`) and "git" (like after
--- `:Git log --patch` or `:Git show` for commit).
--- Works well with |MiniGit.show_range_history()|.
---
--- General idea of folding levels (use |zr| and |zm| to adjust interactively):
--- - At level 0 there is one line per whole patch or log entry.
--- - At level 1 there is one line per patched file.
--- - At level 2 there is one line per hunk.
--- - At level 3 there is no folds.
---
--- For automated setup, set the following for "git" and "diff" filetypes (either
--- inside |FileType| autocommand or |ftplugin|): >vim
---
--- setlocal foldmethod=expr foldexpr=v:lua.MiniGit.diff_foldexpr()
--- <
---@param lnum number|nil Line number for which fold level is computed.
--- Default: |v:lnum|.
---
---@return number|string Line fold level. See |fold-expr|.
MiniGit.diff_foldexpr = function(lnum)
lnum = lnum or vim.v.lnum
if H.is_log_entry_header(lnum + 1) or H.is_log_entry_header(lnum) then return 0 end
if H.is_file_entry_header(lnum) then return 1 end
if H.is_hunk_header(lnum) then return 2 end
if H.is_hunk_header(lnum - 1) then return 3 end
return '='
end
--- Enable Git tracking in a file buffer
---
--- Tracking is done by reacting to changes in file content or file's repository
--- in the form of keeping buffer data up to date. The data can be used via:
--- - |MiniGit.get_buf_data()|. See its help for a list of actually tracked data.
--- - `vim.b.minigit_summary` (table) and `vim.b.minigit_summary_string` (string)
--- buffer-local variables which are more suitable for statusline.
--- `vim.b.minigit_summary_string` contains information about HEAD, file status,
--- and in progress action (see |MiniGit.get_buf_data()| for more details).
--- See |MiniGit-examples| for how it can be tweaked and used in statusline.
---
--- Note: this function is called automatically for all new normal buffers.
--- Use it explicitly if buffer was disabled.
---
--- `User` event `MiniGitUpdated` is triggered whenever tracking data is updated.
--- Note that not all data listed in |MiniGit.get_buf_data()| can be present (yet)
--- at the point of event being triggered.
---
---@param buf_id __git_buf_id
MiniGit.enable = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
-- Don't enable more than once
if H.is_buf_enabled(buf_id) or H.is_disabled(buf_id) or not H.has_git then return end
-- Enable only in buffers which *can* be part of Git repo
local path = H.get_buf_realpath(buf_id)
if path == '' or vim.fn.filereadable(path) ~= 1 then return end
-- Start tracking
H.cache[buf_id] = {}
H.setup_buf_behavior(buf_id)
H.start_tracking(buf_id, path)
end
--- Disable Git tracking in buffer
---
---@param buf_id __git_buf_id
MiniGit.disable = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
local buf_cache = H.cache[buf_id]
if buf_cache == nil then return end
H.cache[buf_id] = nil
-- Cleanup
pcall(vim.api.nvim_del_augroup_by_id, buf_cache.augroup)
vim.b[buf_id].minigit_summary, vim.b[buf_id].minigit_summary_string = nil, nil
-- - Unregister buffer from repo watching with possibly more cleanup
local repo = buf_cache.repo
if H.repos[repo] == nil then return end
H.repos[repo].buffers[buf_id] = nil
if vim.tbl_count(H.repos[repo].buffers) == 0 then
H.teardown_repo_watch(repo)
H.repos[repo] = nil
end
end
--- Toggle Git tracking in buffer
---
--- Enable if disabled, disable if enabled.
---
---@param buf_id __git_buf_id
MiniGit.toggle = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
if H.is_buf_enabled(buf_id) then return MiniGit.disable(buf_id) end
return MiniGit.enable(buf_id)
end
--- Get buffer data
---
---@param buf_id __git_buf_id
---
---@return table|nil Table with buffer Git data or `nil` if buffer is not enabled.
--- If the file is not part of Git repo, table will be empty.
--- Table has the following fields:
--- - <repo> `(string)` - full path to '.git' directory.
--- - <root> `(string)` - full path to worktree root.
--- - <head> `(string)` - full commit of current HEAD.
--- - <head_name> `(string)` - short name of current HEAD (like "master").
--- For detached HEAD it is "HEAD".
--- - <status> `(string)` - two character file status as returned by `git status`.
--- - <in_progress> `(string)` - name of action(s) currently in progress
--- (bisect, merge, etc.). Can be a combination of those separated by ",".
MiniGit.get_buf_data = function(buf_id)
buf_id = H.validate_buf_id(buf_id)
local buf_cache = H.cache[buf_id]
if buf_cache == nil then return nil end
--stylua: ignore
return {
repo = buf_cache.repo, root = buf_cache.root,
head = buf_cache.head, head_name = buf_cache.head_name,
status = buf_cache.status, in_progress = buf_cache.in_progress,
}
end
-- Helper data ================================================================
-- Module default config
H.default_config = MiniGit.config
-- Cache per enabled buffer. Values are tables with fields:
-- - <augroup> - identifier of augroup defining buffer behavior.
-- - <repo> - path to buffer's repo ('.git' directory).
-- - <root> - path to worktree root.
-- - <head> - full commit of `HEAD`.
-- - <head_name> - short name of `HEAD` (`'HEAD'` for detached head).
-- - <status> - current file status.
-- - <in_progress> - string name of action in progress (bisect, merge, etc.)
H.cache = {}
-- Cache per repo (git directory) path. Values are tables with fields:
-- - <fs_event> - `vim.loop` event for watching repo dir.
-- - <timer> - timer to debounce repo changes.
-- - <buffers> - map of buffers which should are part of repo.
H.repos = {}
-- Termporary file used as config for `GIT_EDITOR`
H.git_editor_config = nil
-- Data about supported Git subcommands. Initialized lazily. Fields:
-- - <supported> - array of supported one word commands.
-- - <complete> - array of commands to complete directly after `:Git`.
-- - <info> - map with fields as commands which show something to user.
-- - <options> - map of cached options per command; initialized lazily.
-- - <alias> - map of alias command name to command it implements.
H.git_subcommands = nil
-- Whether to temporarily skip some checks (like when inside `GIT_EDITOR`)
H.skip_timeout = false
H.skip_sync = false
-- Helper functionality =======================================================
-- Settings -------------------------------------------------------------------
H.setup_config = function(config)
H.check_type('config', config, 'table', true)
config = vim.tbl_deep_extend('force', vim.deepcopy(H.default_config), config or {})
H.check_type('job', config.job, 'table')
H.check_type('command', config.command, 'table')
H.check_type('job.git_executable', config.job.git_executable, 'string')
H.check_type('job.timeout', config.job.timeout, 'number')
if not pcall(H.normalize_split_opt, config.command.split) then
H.error('`command.split` should be one of "auto", "horizontal", "vertical", "tab"')
end
return config
end
H.apply_config = function(config) MiniGit.config = config end
H.create_autocommands = function()
local gr = vim.api.nvim_create_augroup('MiniGit', {})
local au = function(event, pattern, callback, desc)
vim.api.nvim_create_autocmd(event, { group = gr, pattern = pattern, callback = callback, desc = desc })
end
-- NOTE: Try auto enabling buffer on every `BufEnter` to not have `:edit`
-- disabling buffer, as it calls `on_detach()` from buffer watcher
au('BufEnter', '*', H.auto_enable, 'Enable Git tracking')
end
H.is_disabled = function(buf_id) return vim.g.minigit_disable == true or vim.b[buf_id or 0].minigit_disable == true end
H.create_user_commands = function()
local opts = { bang = true, nargs = '+', complete = H.command_complete, desc = 'Execute Git command' }
vim.api.nvim_create_user_command('Git', H.command_impl, opts)
end
-- Autocommands ---------------------------------------------------------------
H.auto_enable = vim.schedule_wrap(function(data)
local buf = data.buf
if not (vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].buftype == '' and vim.bo[buf].buflisted) then return end
MiniGit.enable(data.buf)
end)
-- Command --------------------------------------------------------------------
H.command_impl = function(input)
if not H.has_git then
return H.notify('There is no `' .. MiniGit.config.job.git_executable .. '` executable', 'ERROR')
end
H.ensure_git_subcommands()
-- Define Git editor to be used if needed. The way it works is: execute
-- command, wait for it to exit, use content of edited file. So to properly
-- wait for user to finish edit, start fresh headless process which opens
-- file in current session/process. It exits after the user is done editing
-- (deletes the buffer or closes the window).
H.ensure_git_editor(input.mods)
-- NOTE: use `vim.v.progpath` to have same runtime
local editor = H.cli_escape(vim.v.progpath) .. ' --clean --headless -u ' .. H.cli_escape(H.git_editor_config)
-- Setup custom environment variables for better reproducibility
local env_vars = {}
-- - Use Git related variables to use instance for editing
env_vars.GIT_EDITOR, env_vars.GIT_SEQUENCE_EDITOR, env_vars.GIT_PAGER = editor, editor, ''
-- - Make output as much machine readable as possible
env_vars.NO_COLOR, env_vars.TERM = 1, 'dumb'
local env = H.make_spawn_env(env_vars)
-- Setup spawn arguments
local args = vim.tbl_map(H.expandcmd, input.fargs)
local command = { MiniGit.config.job.git_executable, unpack(args) }
local cwd = H.get_git_cwd()
local cmd_data = { cmd_input = input, git_command = command, cwd = cwd }
local is_done_track = { done = false }
local on_done = H.command_make_on_done(cmd_data, is_done_track)
H.cli_run(command, cwd, on_done, { env = env })
-- If needed, synchronously wait for job to finish
local sync_check = function() return H.skip_sync or is_done_track.done end
if not input.bang then vim.wait(MiniGit.config.job.timeout + 10, sync_check, 1) end
end
--stylua: ignore
H.ensure_git_subcommands = function()
if H.git_subcommands ~= nil then return end
local git_subcommands = {}
-- Compute all supported commands. All 'list-' are taken from Git source
-- 'command-list.txt' file. Be so granular and not just `main,nohelpers` in
-- order to not include purely man-page worthy items (like "remote-ext").
local lists_all = {
'list-mainporcelain',
'list-ancillarymanipulators', 'list-ancillaryinterrogators',
'list-foreignscminterface',
'list-plumbingmanipulators', 'list-plumbinginterrogators',
'others', 'alias',
}
local supported = H.git_cli_output({ '--list-cmds=' .. table.concat(lists_all, ',') })
if #supported == 0 then
-- Fall back only on basics if previous one failed for some reason
supported = {
'add', 'bisect', 'branch', 'clone', 'commit', 'diff', 'fetch', 'grep', 'init', 'log', 'merge',
'mv', 'pull', 'push', 'rebase', 'reset', 'restore', 'rm', 'show', 'status', 'switch', 'tag',
}
end
table.sort(supported)
git_subcommands.supported = supported
-- Compute complete list for commands by enhancing with two word commands.
-- Keep those lists manual as there is no good way to compute lazily.
local complete = vim.deepcopy(supported)
local add_twoword = function(prefix, suffixes)
if not vim.tbl_contains(supported, prefix) then return end
for _, suf in ipairs(suffixes) do table.insert(complete, prefix .. ' ' .. suf) end
end
add_twoword('bundle', { 'create', 'list-heads', 'unbundle', 'verify' })
add_twoword('bisect', { 'bad', 'good', 'log', 'replay', 'reset', 'run', 'skip', 'start', 'terms', 'view', 'visualize' })
add_twoword('commit-graph', { 'verify', 'write' })
add_twoword('maintenance', { 'run', 'start', 'stop', 'register', 'unregister' })
add_twoword('multi-pack-index', { 'expire', 'repack', 'verify', 'write' })
add_twoword('notes', { 'add', 'append', 'copy', 'edit', 'get-ref', 'list', 'merge', 'prune', 'remove', 'show' })
add_twoword('p4', { 'clone', 'rebase', 'submit', 'sync' })
add_twoword('reflog', { 'delete', 'exists', 'expire', 'show' })
add_twoword('remote', { 'add', 'get-url', 'prune', 'remove', 'rename', 'rm', 'set-branches', 'set-head', 'set-url', 'show', 'update' })
add_twoword('rerere', { 'clear', 'diff', 'forget', 'gc', 'remaining', 'status' })
add_twoword('sparse-checkout', { 'add', 'check-rules', 'disable', 'init', 'list', 'reapply', 'set' })
add_twoword('stash', { 'apply', 'branch', 'clear', 'create', 'drop', 'list', 'pop', 'save', 'show', 'store' })
add_twoword('submodule', { 'absorbgitdirs', 'add', 'deinit', 'foreach', 'init', 'set-branch', 'set-url', 'status', 'summary', 'sync', 'update' })
add_twoword('subtree', { 'add', 'merge', 'pull', 'push', 'split' })
add_twoword('worktree', { 'add', 'list', 'lock', 'move', 'prune', 'remove', 'repair', 'unlock' })
git_subcommands.complete = complete
-- Compute commands which are meant to show information. These will show CLI
-- output in separate buffer opposed to `vim.notify`.
local info_args = { '--list-cmds=list-info,list-ancillaryinterrogators,list-plumbinginterrogators' }
local info_commands = H.git_cli_output(info_args)
if #info_commands == 0 then info_commands = { 'bisect', 'diff', 'grep', 'log', 'show', 'status' } end
local info = {}
for _, cmd in ipairs(info_commands) do
info[cmd] = true
end
git_subcommands.info = info
-- Compute commands which aliases rely on
local alias_data = H.git_cli_output({ 'config', '--get-regexp', 'alias.*' })
local alias = {}
for _, l in ipairs(alias_data) do
-- Assume simple alias of the form `alias.xxx subcommand ...`
local alias_cmd, cmd = string.match(l, '^alias%.(%S+) (%S+)')
if vim.tbl_contains(supported, cmd) then alias[alias_cmd] = cmd end
end
git_subcommands.alias = alias
-- Initialize cache for command options. Initialize with `false` so that
-- actual values are computed lazily when needed for a command.
local options = { git = false }
for _, command in ipairs(supported) do
options[command] = false
end
git_subcommands.options = options
-- Cache results
H.git_subcommands = git_subcommands
end
H.ensure_git_editor = function(mods)
if H.git_editor_config == nil or not vim.fn.filereadable(H.git_editor_config) == 0 then
H.git_editor_config = vim.fn.tempname()
end
-- Create a private function responsible for editing Git file
MiniGit._edit = function(path, servername)
-- Define editor state before and after editing path
H.skip_timeout, H.skip_sync = true, true
local cleanup = function()
local _, channel = pcall(vim.fn.sockconnect, 'pipe', servername, { rpc = true })
pcall(vim.rpcnotify, channel, 'nvim_exec2', 'quitall!', {})
H.skip_timeout, H.skip_sync = false, false
end
-- Start file edit with proper modifiers in a special window
mods = H.ensure_mods_is_split(mods)
vim.cmd(mods .. ' split ' .. vim.fn.fnameescape(path))
H.define_minigit_window(cleanup)
end
-- Start editing file from first argument (as how `GIT_EDITOR` works) in
-- current instance and don't close until explicitly closed later from this
-- instance as set up in `MiniGit._edit()`
local lines = {
'lua << EOF',
string.format('local channel = vim.fn.sockconnect("pipe", %s, { rpc = true })', vim.inspect(vim.v.servername)),
'local ins = vim.inspect',
'local lua_cmd = string.format("MiniGit._edit(%s, %s)", ins(vim.fn.argv(0)), ins(vim.v.servername))',
'vim.rpcrequest(channel, "nvim_exec_lua", lua_cmd, {})',
'EOF',
}
vim.fn.writefile(lines, H.git_editor_config)
end
H.get_git_cwd = function()
local buf_cache = H.cache[vim.api.nvim_get_current_buf()] or {}
return buf_cache.root or vim.fn.getcwd()
end
H.command_make_on_done = function(cmd_data, is_done_track)
return vim.schedule_wrap(function(code, out, err)
-- Register that command is done executing (to enable sync execution)
is_done_track.done = true
-- Trigger "done" event
cmd_data.git_subcommand = H.command_parse_subcommand(cmd_data.git_command)
cmd_data.exit_code, cmd_data.stdout, cmd_data.stderr = code, out, err
H.trigger_event('MiniGitCommandDone', cmd_data)
-- Show stderr and stdout
if H.cli_err_notify(code, out, err) then return end
H.command_show_stdout(cmd_data)
-- Ensure that all buffers are up to date (avoids "The file has been
-- changed since reading it" warning)
vim.tbl_map(function(buf_id) vim.cmd('checktime ' .. buf_id) end, vim.api.nvim_list_bufs())
end)
end
H.command_show_stdout = function(cmd_data)
local stdout, mods, subcommand = cmd_data.stdout, cmd_data.cmd_input.mods, cmd_data.git_subcommand
if stdout == '' or (mods:find('silent') ~= nil and mods:find('unsilent') == nil) then return end
-- Show in split if explicitly forced or the command shows info.
-- Use `vim.notify` otherwise.
local should_split = H.mods_is_split(mods) or H.git_subcommands.info[subcommand]
if not should_split then return H.notify(stdout, 'INFO') end
local lines = vim.split(stdout, '\n')
local name = table.concat(cmd_data.git_command, ' ')
cmd_data.win_source, cmd_data.win_stdout = H.show_in_split(mods, lines, subcommand, name)
-- Trigger "split" event
H.trigger_event('MiniGitCommandSplit', cmd_data)
end
H.command_parse_subcommand = function(command)
local res
for _, cmd in ipairs(command) do
if res == nil and vim.tbl_contains(H.git_subcommands.supported, cmd) then res = cmd end
end
return H.git_subcommands.alias[res] or res
end
H.command_complete = function(_, line, col)
-- Compute completion base manually to be "at cursor" and respect `\ `
local base = H.get_complete_base(line:sub(1, col))
local candidates, compl_type = H.command_get_complete_candidates(line, col, base)
-- Allow several "//" at the end for path completion for easier "chaining"
if compl_type == 'path' then base = base:gsub('/+$', '/') end
return vim.tbl_filter(function(x) return vim.startswith(x, base) end, candidates)
end
H.get_complete_base = function(line)
local from, _, res = line:find('(%S*)$')
while from ~= nil do
local cur_from, _, cur_res = line:sub(1, from - 1):find('(%S*\\ )$')
if cur_res ~= nil then res = cur_res .. res end
from = cur_from
end
return (res:gsub([[\ ]], ' '))
end
H.command_get_complete_candidates = function(line, col, base)
H.ensure_git_subcommands()
-- Determine current Git subcommand as the earliest present supported one
local subcmd, subcmd_end = nil, math.huge
for _, cmd in pairs(H.git_subcommands.supported) do
local _, ind = line:find(' ' .. cmd .. ' ', 1, true)
if ind ~= nil and ind < subcmd_end then
subcmd, subcmd_end = cmd, ind
end
end
subcmd = subcmd or 'git'
local cwd = H.get_git_cwd()
-- Determine command candidates:
-- - Commannd options if complete base starts with "-".
-- - Paths if after explicit "--".
-- - Git commands if there is none fully formed yet or cursor is at the end
-- of the command (to also suggest subcommands).
-- - Command targets specific for each command (if present).
if vim.startswith(base, '-') then return H.command_complete_option(subcmd) end
if line:sub(1, col):find(' -- ') ~= nil then return H.command_complete_path(cwd, base) end
if subcmd_end == math.huge or (subcmd_end - 1) == col then return H.git_subcommands.complete, 'subcommand' end
subcmd = H.git_subcommands.alias[subcmd] or subcmd
local complete_targets = H.command_complete_subcommand_targets[subcmd]
if complete_targets == nil then return {}, nil end
return complete_targets(cwd, base, line)
end
H.command_complete_option = function(command)
local cached_candidates = H.git_subcommands.options[command]
if cached_candidates == nil then return {} end
if type(cached_candidates) == 'table' then return cached_candidates end
-- Use alias's command to compute the options but store cache for alias
local orig_command = command
command = H.git_subcommands.alias[command] or command
-- Find command's flag options by parsing its help page. Needs a bit
-- heuristic approach and ensuring proper `git help` output (as it is done
-- through `man`), but seems to work good enough.
-- Alternative is to call command with `--git-completion-helper-all` flag (as
-- is done in bash and vim-fugitive completion). This has both pros and cons:
-- - Pros: faster; more targeted suggestions (like for two word subcommands);
-- presumably more reliable.
-- - Cons: works on smaller number of commands (for example, `rev-parse` or
-- pure `git` do not work); does not provide single dash suggestions;
-- does not work when not inside Git repo; needs recognizing two word
-- commands before asking for completion.
local env = H.make_spawn_env({ MANPAGER = 'cat', NO_COLOR = 1, PAGER = 'cat' })
local lines = H.git_cli_output({ 'help', '--man', command }, nil, env)
-- - Exit early before caching to try again later
if #lines == 0 then return {} end
-- - On some systems (like Mac), output still might contain formatting
-- sequences, like "a\ba" and "_\ba" meaning bold and italic.
-- See https://github.com/nvim-mini/mini.nvim/issues/918
lines = vim.tbl_map(function(l) return l:gsub('.\b', '') end, lines)
-- Construct non-duplicating candidates by parsing lines of help page
local candidates_map = {}
-- Options are assumed to be listed inside "OPTIONS" or "XXX OPTIONS" (like
-- "MODE OPTIONS" of `git rebase`) section on dedicated lines. Whether a line
-- contains only options is determined heuristically: it is assumed to start
-- exactly with " -" indicating proper indent for subsection start.
-- Known not parsable options:
-- - `git reset <mode>` (--soft, --hard, etc.): not listed in "OPTIONS".
-- - All -<number> options, as they are not really completeable.
local is_in_options_section = false
for _, l in ipairs(lines) do
if is_in_options_section and l:find('^%u[%u ]+$') ~= nil then is_in_options_section = false end
if not is_in_options_section and l:find('^%u?[%u ]*OPTIONS$') ~= nil then is_in_options_section = true end
if is_in_options_section and l:find('^ %-') ~= nil then H.parse_options(candidates_map, l) end
end
-- Finalize candidates. Should not contain "almost duplicates".
-- Should also be sorted by relevance: short flags before regular flags.
-- Inside groups sort alphabetically ignoring case.
candidates_map['--'] = nil
for cmd, _ in pairs(candidates_map) do
-- There can be two explicitly documented options "--xxx" and "--xxx=".
-- Use only one of them (without "=").
if cmd:sub(-1, -1) == '=' and candidates_map[cmd:sub(1, -2)] ~= nil then candidates_map[cmd] = nil end
end
local res = vim.tbl_keys(candidates_map)
table.sort(res, function(a, b)
local a2, b2 = a:sub(2, 2) == '-', b:sub(2, 2) == '-'
if a2 and not b2 then return false end
if not a2 and b2 then return true end
local a_low, b_low = a:lower(), b:lower()
return a_low < b_low or (a_low == b_low and a < b)
end)
-- Cache and return
H.git_subcommands.options[orig_command] = res
return res, 'option'
0