Skip to content

Commit

Permalink
feat(git): implement method to fetch git status of files in a folder
Browse files Browse the repository at this point in the history
  • Loading branch information
pysan3 committed Dec 7, 2023
1 parent 5f0f368 commit 88d73b9
Show file tree
Hide file tree
Showing 2 changed files with 221 additions and 1 deletion.
200 changes: 200 additions & 0 deletions lua/pathlib/git.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
local const = require("pathlib.const")
local utils = require("pathlib.utils")

---@class PathlibGit
local M = {}

---Find closest directory that contains `.git` directory, meaning that it's a root of a git repository
---@param current_focus PathlibPath?
function M.find_root(current_focus)
vim.print("find_root")
vim.print(
string.format([[utils.tables.type_of(current_focus): %s]], vim.inspect(utils.tables.type_of(current_focus)))
)
if not current_focus or current_focus:is_dir() and current_focus:__div(".git"):exists() then
return current_focus
end
for parent in current_focus:parents() do
if parent:__div(".git"):exists() then
return parent
end
end
end

---Map simple status string notation into PathlibGitStatus
---@param status string
---@return PathlibGitStatus
local function git_simple_status_to_enum(status)
---@type PathlibGitStatus
local result = {}
local x, y = status:sub(1, 1), status:sub(2, 2)
for key, value in pairs(const.git_status) do
if x == value then
result[1] = const.git_status[key]
end
if y == value then
result[2] = const.git_status[key]
end
end
return result
end

---Parse the git status
---@param status_string string
---@return PathlibGitStatus
function M.get_simple_git_status_code(status_string)
-- Prioritze M then A over all others
if status_string:match("U") or status_string == "AA" or status_string == "DD" then
return { const.git_status.UNMODIFIED }
elseif status_string:match("M") then
return { const.git_status.MODIFIED }
elseif status_string:match("[ACR]") then
return { const.git_status.ADDED }
elseif status_string:match("!$") then
return { const.git_status.IGNORED }
elseif status_string:match("?$") then
return { const.git_status.UNTRACKED }
else
local len = #status_string
while len > 0 do
local char = status_string:sub(len, len)
if char ~= " " then
return git_simple_status_to_enum(char)
end
len = len - 1
end
return git_simple_status_to_enum(status_string)
end
end

---Get the most significant git status among
---@param status PathlibGitStatusEnum?
---@param other_status PathlibGitStatusEnum?
---@return PathlibGitStatusEnum?
function M.get_priority_git_status_code(status, other_status)
if not status then
return other_status
elseif not other_status then
return status
else
local g = const.git_status
for _, st in ipairs({ g.UPDATED_BUT_UNMERGED, g.UNTRACKED, g.MODIFIED, g.ADDED }) do
if status == st or other_status == st then
return st
end
end
return status
end
end

---git uses octal encoding for utf-8 filepaths, convert octal back to utf-8
---@param text string
---@return string # Converted string encoded with utf8
function M.octal_to_utf8(text)
local function convert_octal_char(octal)
return string.char(tonumber(octal, 8))
end
local success, converted = pcall(string.gsub, text, "\\([0-7][0-7][0-7])", convert_octal_char)
return success and converted or text
end

---Parse and return status of git status output.
---@param line string # One line of git status output.
---@param git_status table<PathlibString, PathlibGitStatus>
---@param update_parent_dirs boolean # If true, updates status of parent dirs by merging the results of children.
---@param git_root PathlibPath
local function parse_git_status_line(line, git_status, update_parent_dirs, git_root)
if type(line) ~= "string" then
return
end
if #line < 4 then
return
end
local line_parts = vim.split(line, "\t")
if #line_parts < 2 then
return
end

local status_string = line_parts[1]
if status_string:match("^R") then -- is rename
status_string = line_parts[3]
end
local status = M.get_simple_git_status_code(status_string)
local relative_path = line_parts[2]
-- remove any " due to whitespace or utf-8 in the path
relative_path = relative_path:gsub('^"', ""):gsub('"$', "")
-- convert octal encoded lines to utf-8
relative_path = M.octal_to_utf8(relative_path)

local absolute_path = git_root / relative_path
local string_path = absolute_path:tostring()
-- merge status result if there are results from multiple passes
local existing_status = git_status[string_path] or {}
status[1] = M.get_priority_git_status_code(existing_status[1], status[1])
status[2] = M.get_priority_git_status_code(existing_status[2], status[2])
git_status[string_path] = status
if update_parent_dirs then
-- Now bubble this status up to the parent directories
for parent in absolute_path:parents() do
local parent_string = parent:tostring()
if not git_status[parent_string] then
git_status[parent_string] = {}
end
local parent_status = git_status[parent_string]
parent_status[1] = M.get_priority_git_status_code(parent_status[1], status[1])
parent_status[2] = M.get_priority_git_status_code(parent_status[2], status[2])
end
end
end

---Fetch the status of files in a git repository.
---@param root_path PathlibPath
---@param update_parent_dirs boolean # If true, updates status of parent dirs by merging the results of children.
---@param commit_base string? # Commit to compare against. If nil, uses `HEAD`.
---@return table<PathlibString, PathlibGitStatus> git_status
---@return PathlibPath git_root
function M.status(root_path, update_parent_dirs, commit_base)
local git_root = M.find_root(root_path)
if not git_root or not git_root:is_dir() or not git_root:exists() then
return {}, git_root
end
if not commit_base or commit_base:len() == 0 then
commit_base = "HEAD"
end
local C = git_root:tostring()
local staged_cmd = { "git", "-C", C, "diff", "--staged", "--name-status", commit_base, "--" }
local staged_ok, staged_result = utils.execute_command(staged_cmd)
if not staged_ok then
return {}, git_root
end
local unstaged_cmd = { "git", "-C", C, "diff", "--name-status" }
local unstaged_ok, unstaged_result = utils.execute_command(unstaged_cmd)
if not unstaged_ok then
return {}, git_root
end
local untracked_cmd = { "git", "-C", C, "ls-files", "--exclude-standard", "--others" }
local untracked_ok, untracked_result = utils.execute_command(untracked_cmd)
if not untracked_ok then
return {}, git_root
end

---@type table<PathlibString, PathlibGitStatus>
local git_status = {}
for _, line in ipairs(staged_result) do
parse_git_status_line(line, git_status, update_parent_dirs, git_root)
end
for _, line in ipairs(unstaged_result) do
if line then
parse_git_status_line(" " .. line, git_status, update_parent_dirs, git_root)
end
end
for _, line in ipairs(untracked_result) do
if line then
parse_git_status_line("? \t" .. line, git_status, update_parent_dirs, git_root)
end
end

return git_status, git_root
end

return M
22 changes: 21 additions & 1 deletion lua/pathlib/utils/init.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,24 @@
return {
local M = {
tables = require("pathlib.utils.tables"),
lists = require("pathlib.utils.lists"),
}

---@return PathlibPath|PathlibWindowsPath|PathlibPosixPath
function M.importPath()
return require("pathlib") ---@diagnostic disable-line
end

---Execute command via `systemlist` and return its status as well.
---@param cmd string[] # Command to execute as a list of strings.
---@return boolean success
---@return string[] result_lines # Each line of the output from the command.
function M.execute_command(cmd)
local result = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then
return false, {}
else
return true, result
end
end

return M

0 comments on commit 88d73b9

Please sign in to comment.