Skip to content

Instantly share code, notes, and snippets.

@bassamsdata
Last active August 28, 2024 23:45
Show Gist options
  • Save bassamsdata/eec0a3065152226581f8d4244cce9051 to your computer and use it in GitHub Desktop.
Save bassamsdata/eec0a3065152226581f8d4244cce9051 to your computer and use it in GitHub Desktop.
MiniFiles Git integration

Below is a code for Minifiles Git integration code snippet.

How to use it

Just insert the code below into this function in your Minifiles config:

config = function()
-- add the git code here
end

Screenshot:

Screenshot 2024-04-16 at 9 53 57 PM

Some Notes:

  • It requires the latest version of mini.files.
  • it requires neovim v0.10.0 or later, for previous versions please check the revison of this gist for function(fetchGitStatus) specifically.
  • it works on mac, linux or windows.
  • The shell command git status is executed once per Minifiles session for performance reasons, leveraging simple cache integration.
  • the code is efficient and shell command executes asyncronously for performance optimization.
  • You have the option to change symbols and highlight groups to GitSigns if preferred. Currently, it's using Mini.Diff.
  • If you prefer symbols on the right, they're commented out. Refer to the NOTE in the code.

TODOs and some limitation:

  • Git ignore support isn't implemented yet, but it's feasible and might be added in the future.
  • It doesn't check for Git outside of the current working directory (cwd) due to caching considerations. This might be revisited in the future.
  • currently, it doesn't work if preview was on
  • The code will be simpler and more efficient when this issue echasnovski/mini.nvim#817 is resolved.

NOTE:

  • I'm open to feedback, suggestions, or even criticism.
  • If you have a better idea for implementation, please share!

Thanks:

local nsMiniFiles = vim.api.nvim_create_namespace("mini_files_git")
local autocmd = vim.api.nvim_create_autocmd
local _, MiniFiles = pcall(require, "mini.files")
-- Cache for git status
local gitStatusCache = {}
local cacheTimeout = 2000 -- Cache timeout in milliseconds
---@type table<string, {symbol: string, hlGroup: string}>
---@param status string
---@return string symbol, string hlGroup
local function mapSymbols(status)
local statusMap = {
-- stylua: ignore start
[" M"] = { symbol = "•", hlGroup = "GitSignsChange"}, -- Modified in the working directory
["M "] = { symbol = "✹", hlGroup = "GitSignsChange"}, -- modified in index
["MM"] = { symbol = "≠", hlGroup = "GitSignsChange"}, -- modified in both working tree and index
["A "] = { symbol = "+", hlGroup = "GitSignsAdd" }, -- Added to the staging area, new file
["AA"] = { symbol = "≈", hlGroup = "GitSignsAdd" }, -- file is added in both working tree and index
["D "] = { symbol = "-", hlGroup = "GitSignsDelete"}, -- Deleted from the staging area
["AM"] = { symbol = "⊕", hlGroup = "GitSignsChange"}, -- added in working tree, modified in index
["AD"] = { symbol = "-•", hlGroup = "GitSignsChange"}, -- Added in the index and deleted in the working directory
["R "] = { symbol = "→", hlGroup = "GitSignsChange"}, -- Renamed in the index
["U "] = { symbol = "‖", hlGroup = "GitSignsChange"}, -- Unmerged path
["UU"] = { symbol = "⇄", hlGroup = "GitSignsAdd" }, -- file is unmerged
["UA"] = { symbol = "⊕", hlGroup = "GitSignsAdd" }, -- file is unmerged and added in working tree
["??"] = { symbol = "?", hlGroup = "GitSignsDelete"}, -- Untracked files
["!!"] = { symbol = "!", hlGroup = "GitSignsChange"}, -- Ignored files
-- stylua: ignore end
}
local result = statusMap[status]
or { symbol = "?", hlGroup = "NonText" }
return result.symbol, result.hlGroup
end
---@param cwd string
---@param callback function
---@return nil
local function fetchGitStatus(cwd, callback)
local function on_exit(content)
if content.code == 0 then
callback(content.stdout)
vim.g.content = content.stdout
end
end
vim.system(
{ "git", "status", "--ignored", "--porcelain" },
{ text = true, cwd = cwd },
on_exit
)
end
---@param str string?
local function escapePattern(str)
return str:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
end
---@param buf_id integer
---@param gitStatusMap table
---@return nil
local function updateMiniWithGit(buf_id, gitStatusMap)
vim.schedule(function()
local nlines = vim.api.nvim_buf_line_count(buf_id)
local cwd = vim.fs.root(buf_id, ".git")
local escapedcwd = escapePattern(cwd)
if vim.fn.has("win32") == 1 then
escapedcwd = escapedcwd:gsub("\\", "/")
end
for i = 1, nlines do
local entry = MiniFiles.get_fs_entry(buf_id, i)
if not entry then
break
end
local relativePath = entry.path:gsub("^" .. escapedcwd .. "/", "")
local status = gitStatusMap[relativePath]
if status then
local symbol, hlGroup = mapSymbols(status)
vim.api.nvim_buf_set_extmark(buf_id, nsMiniFiles, i - 1, 0, {
-- NOTE: if you want the signs on the right uncomment those and comment
-- the 3 lines after
-- virt_text = { { symbol, hlGroup } },
-- virt_text_pos = "right_align",
sign_text = symbol,
sign_hl_group = hlGroup,
priority = 2,
})
else
end
end
end)
end
-- Thanks for the idea of gettings https://github.com/refractalize/oil-git-status.nvim signs for dirs
---@param content string
---@return table
local function parseGitStatus(content)
local gitStatusMap = {}
-- lua match is faster than vim.split (in my experience )
for line in content:gmatch("[^\r\n]+") do
local status, filePath = string.match(line, "^(..)%s+(.*)")
-- Split the file path into parts
local parts = {}
for part in filePath:gmatch("[^/]+") do
table.insert(parts, part)
end
-- Start with the root directory
local currentKey = ""
for i, part in ipairs(parts) do
if i > 1 then
-- Concatenate parts with a separator to create a unique key
currentKey = currentKey .. "/" .. part
else
currentKey = part
end
-- If it's the last part, it's a file, so add it with its status
if i == #parts then
gitStatusMap[currentKey] = status
else
-- If it's not the last part, it's a directory. Check if it exists, if not, add it.
if not gitStatusMap[currentKey] then
gitStatusMap[currentKey] = status
end
end
end
end
return gitStatusMap
end
---@param buf_id integer
---@return nil
local function updateGitStatus(buf_id)
if not vim.fs.root(vim.uv.cwd(), ".git") then
return
end
local cwd = vim.fn.expand("%:p:h")
local currentTime = os.time()
if
gitStatusCache[cwd]
and currentTime - gitStatusCache[cwd].time < cacheTimeout
then
updateMiniWithGit(buf_id, gitStatusCache[cwd].statusMap)
else
fetchGitStatus(cwd, function(content)
local gitStatusMap = parseGitStatus(content)
gitStatusCache[cwd] = {
time = currentTime,
statusMap = gitStatusMap,
}
updateMiniWithGit(buf_id, gitStatusMap)
end)
end
end
---@return nil
local function clearCache()
gitStatusCache = {}
end
local function augroup(name)
return vim.api.nvim_create_augroup(
"MiniFiles_" .. name,
{ clear = true }
)
end
autocmd("User", {
group = augroup("start"),
pattern = "MiniFilesExplorerOpen",
-- pattern = { "minifiles" },
callback = function()
local bufnr = vim.api.nvim_get_current_buf()
updateGitStatus(bufnr)
end,
})
autocmd("User", {
group = augroup("close"),
pattern = "MiniFilesExplorerClose",
callback = function()
clearCache()
end,
})
autocmd("User", {
group = augroup("update"),
pattern = "MiniFilesBufferUpdate",
callback = function(sii)
local bufnr = sii.data.buf_id
local cwd = vim.fn.expand("%:p:h")
if gitStatusCache[cwd] then
updateMiniWithGit(bufnr, gitStatusCache[cwd].statusMap)
end
end,
})
@WizardStark
Copy link

Thanks for this, its something I've been missing. Some updates that I made to suite my usecase - I often work in large repos and then my cwd != git root, but I would still like to see the git status in this case, so just tweaking updateMiniWithGit and updateGitStatus as follows, solved this:

local function updateMiniWithGit(buf_id, gitStatusMap)
	local MiniFiles = require("mini.files")
	vim.schedule(function()
		local nlines = vim.api.nvim_buf_line_count(buf_id)
		local git_root = vim.trim(vim.fn.system("git rev-parse --show-toplevel"))
		local escapedcwd = escapePattern(git_root)
		if vim.fn.has("win32") == 1 then
			escapedcwd = escapedcwd:gsub("\\", "/")
		end

		for i = 1, nlines do
			local entry = MiniFiles.get_fs_entry(buf_id, i)
			if not entry then
				break
			end
			local relativePath = entry.path:gsub("^" .. escapedcwd .. "/", "")
			local status = gitStatusMap[relativePath]

			if status then
				local symbol, hlGroup = mapSymbols(status)
				vim.api.nvim_buf_set_extmark(buf_id, nsMiniFiles, i - 1, 0, {
					-- NOTE: if you want the signs on the right uncomment those and comment
					-- the 3 lines after
					-- virt_text = { { symbol, hlGroup } },
					-- virt_text_pos = "right_align",
					sign_text = symbol,
					sign_hl_group = hlGroup,
					priority = 2,
				})
			else
			end
		end
	end)
end

local function updateGitStatus(buf_id)
	if vim.fn.system("git rev-parse --show-toplevel 2> /dev/null") == "" then
		vim.notify("Not a valid git repo")
		return
	end
	local cwd = vim.fn.expand("%:p:h")
	local currentTime = os.time()
	if gitStatusCache[cwd] and currentTime - gitStatusCache[cwd].time < cacheTimeout then
		updateMiniWithGit(buf_id, gitStatusCache[cwd].statusMap)
	else
		fetchGitStatus(cwd, function(content)
			local gitStatusMap = parseGitStatus(content)
			gitStatusCache[cwd] = {
				time = currentTime,
				statusMap = gitStatusMap,
			}
			updateMiniWithGit(buf_id, gitStatusMap)
		end)
	end
end

@bassamsdata
Copy link
Author

Thanks @WizardStark
That's a good point, and I considered it when I implemented it. However, I opted for my solution because I thought most people wouldn't start Neovim from a subdirectory inside a Git repo. But I understand that people have different use cases.

One suggestion, if you're using Neovim nightly, is to utilize the Neovim official API instead of directly calling Git.
For instance, instead of:

local git_root = vim.trim(vim.fn.system("git rev-parse --show-toplevel")) 

You can use:

local root_dir = vim.fs.root(vim.uv.cwd(), ".git")

Similarly, instead of:

if vim.fn.system("git rev-parse --show-toplevel 2> /dev/null") == "" then

You can use:

if not vim.fs.root(vim.uv.cwd(), ".git") then

@theammir
Copy link

theammir commented Aug 17, 2024

Hi! How do I properly swap mini.diff with gitsigns?
Just tried changing hlgroups to GitSigns... (found them here) (ok, there's gitsigns-highlight-groups help entry, i simply used Add, Change, Delete ones), and it doesn't seem to work after that.

@theammir
Copy link

theammir commented Aug 17, 2024

Hi! How do I properly swap mini.diff with gitsigns? Just tried changing hlgroups to GitSigns... (found them here) (ok, there's gitsigns-highlight-groups help entry, i simply used Add, Change, Delete ones), and it doesn't seem to work after that.

After further investigation, it does work with a different repo, just not ~/.config/nvim. Does it matter that the latter one is a renamed submodule?
Went with @WizardStark's approach for now, it resolves the issue.

@bassamsdata
Copy link
Author

Hey @theammir

just not ~/.config/nvim. Does it matter that the latter one is a renamed submodule?

I updated the module to work with async functions and the latest vim.fs.root(). I believe it should work better now and be more performant than WizardStark's version since it doesn't call Git multiple times.

Just tried changing hlgroups to GitSigns...

I updated the module to use GitSigns highlights since everyone is using GitSigns.

Please note this module requires nvim version 0.10.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment