local diff = require("git.diff") local log = require("log") local repo = require("git.repo") local M = {} local SECTIONS = { "Untracked", "Unstaged", "Staged", "Unmerged", "Unpushed", "Unpulled", } local SIDEBAR_WIDTH = 50 ---@class ow.Git.FileEntry ---@field section string ---@field path string ---@field orig string? ---@field x string? ---@field y string? ---@class ow.Git.CommitEntry ---@field section string ---@field sha string ---@field subject string? ---@alias ow.Git.StatusEntry ow.Git.FileEntry | ow.Git.CommitEntry ---@class ow.Git.StatusState ---@field gitdir string ---@field worktree string ---@field lines table ---@field diff_left_win integer? ---@field diff_right_win integer? ---@field user_aucmd integer? ---@field last_shown_key string? ---@type table local state = {} local group = vim.api.nvim_create_augroup("ow.git.status_win", { clear = false }) local ns = vim.api.nvim_create_namespace("ow.git.status_win") ---@return integer? win ---@return integer? bufnr local function find_sidebar() for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do local buf = vim.api.nvim_win_get_buf(win) if vim.bo[buf].filetype == "gitstatus" then return win, buf end end end ---@param entry ow.Git.StatusEntry ---@return string? local function entry_code(entry) if entry.section == "Untracked" then return "??" elseif entry.section == "Unmerged" then return (entry.x or " ") .. (entry.y or " ") elseif entry.section == "Staged" then return (entry.x or " ") .. " " elseif entry.section == "Unstaged" then return " " .. (entry.y or " ") end end ---@param entry ow.Git.StatusEntry ---@return string? line ---@return string? hl_group ---@return integer? hl_len byte length of the symbol portion at column 2 local function format_entry(entry) if entry.sha then return string.format(" %s %s", entry.sha, entry.subject or ""), "GitSha", #entry.sha end local code = entry_code(entry) if not code then return nil end local char, hl = repo.indicator(code) if not char then return nil end local label = entry.orig and (entry.orig .. " -> " .. entry.path) or entry.path return string.format(" %s %s", char, label), hl, #char end ---@class ow.Git.BranchInfo ---@field head string? ---@field upstream string? ---@field ahead integer ---@field behind integer ---@param line string '## branch.line' from porcelain v1 ---@return ow.Git.BranchInfo local function parse_branch_line(line) local info = { ahead = 0, behind = 0 } local content = line:sub(4) local arrow = content:find("...", 1, true) if not arrow then info.head = content return info end info.head = content:sub(1, arrow - 1) local rest = content:sub(arrow + 3) local bracket = rest:find(" %[") if not bracket then info.upstream = rest return info end info.upstream = rest:sub(1, bracket - 1) local inside = rest:match("%[([^%]]+)%]") if inside then info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]] info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]] end return info end ---@param worktree string ---@param callback fun(branch: ow.Git.BranchInfo, groups: table) local function fetch_status(worktree, callback) vim.system({ "git", "-c", "core.quotePath=false", "status", "--porcelain=v1", "--branch", }, { cwd = worktree, text = true }, function(obj) vim.schedule(function() local branch = { ahead = 0, behind = 0 } local groups = { Untracked = {}, Unstaged = {}, Staged = {}, Unmerged = {}, Unpushed = {}, Unpulled = {}, } if obj.code == 0 then for line in (obj.stdout or ""):gmatch("[^\r\n]+") do if line:sub(1, 2) == "##" then branch = parse_branch_line(line) else local x = line:sub(1, 1) local y = line:sub(2, 2) local rest = line:sub(4) local orig local arrow = rest:find(" -> ", 1, true) if arrow then orig = rest:sub(1, arrow - 1) rest = rest:sub(arrow + 4) end local entry = { section = nil, path = rest, orig = orig, x = x, y = y, } if x == "?" and y == "?" then entry.section = "Untracked" table.insert(groups.Untracked, entry) elseif repo.UNMERGED[x .. y] then entry.section = "Unmerged" table.insert(groups.Unmerged, entry) else if x ~= " " then table.insert( groups.Staged, vim.tbl_extend( "force", entry, { section = "Staged" } ) ) end if y ~= " " then table.insert( groups.Unstaged, vim.tbl_extend( "force", entry, { section = "Unstaged" } ) ) end end end end end local fetches = {} if branch.upstream and branch.ahead > 0 then table.insert( fetches, { section = "Unpushed", range = "@{upstream}..HEAD" } ) end if branch.upstream and branch.behind > 0 then table.insert( fetches, { section = "Unpulled", range = "HEAD..@{upstream}" } ) end if #fetches == 0 then callback(branch, groups) return end local pending = #fetches for _, f in ipairs(fetches) do vim.system({ "git", "log", "--format=%h %s", f.range, }, { cwd = worktree, text = true }, function( log_obj ) vim.schedule(function() if log_obj.code == 0 then for line in (log_obj.stdout or ""):gmatch("[^\r\n]+") do local sha, subject = line:match("^(%S+)%s+(.+)$") if sha then table.insert(groups[f.section], { section = f.section, sha = sha, subject = subject, }) end end end pending = pending - 1 if pending == 0 then callback(branch, groups) end end) end) end end) end) end ---@param bufnr integer ---@param branch ow.Git.BranchInfo ---@param groups table local function render(bufnr, branch, groups) local lines = { "Head: " .. (branch.head or "?") } if branch.upstream then local push = "Push: " .. branch.upstream if branch.ahead > 0 then push = push .. " +" .. branch.ahead end if branch.behind > 0 then push = push .. " -" .. branch.behind end table.insert(lines, push) end table.insert(lines, "") local meta = {} local marks = {} for _, section in ipairs(SECTIONS) do local entries = groups[section] if entries and #entries > 0 then table.insert(lines, string.format("%s (%d)", section, #entries)) for _, entry in ipairs(entries) do local line, hl, hl_len = format_entry(entry) if line then table.insert(lines, line) meta[#lines] = entry if hl and hl_len then table.insert(marks, { row = #lines - 1, col = 2, end_col = 2 + hl_len, hl = hl, }) end end end table.insert(lines, "") end end vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modifiable = false vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) for _, m in ipairs(marks) do vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, { end_col = m.end_col, hl_group = m.hl, }) end state[bufnr].lines = meta end ---@param bufnr integer local function refresh(bufnr) local s = state[bufnr] if not s then return end local saved_path, saved_sha for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == bufnr then local lnum = vim.api.nvim_win_get_cursor(win)[1] local entry = s.lines[lnum] if entry then saved_path = entry.path saved_sha = entry.sha end break end end fetch_status(s.worktree, function(branch, groups) if not vim.api.nvim_buf_is_valid(bufnr) then return end render(bufnr, branch, groups) if not saved_path and not saved_sha then return end for lnum, entry in pairs(s.lines) do if (saved_path and entry.path == saved_path) or (saved_sha and entry.sha == saved_sha) then for _, win in ipairs(vim.api.nvim_list_wins()) do if vim.api.nvim_win_get_buf(win) == bufnr then pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 }) end end break end end end) end ---@param bufnr integer ---@return ow.Git.StatusState? ---@return ow.Git.StatusEntry? local function current_entry(bufnr) local s = state[bufnr] if not s then return nil, nil end local lnum = vim.api.nvim_win_get_cursor(0)[1] return s, s.lines[lnum] end ---@class ow.Git.DiffSide ---@field buf integer ---@field name string? ---@class ow.Git.DiffPair ---@field left ow.Git.DiffSide ---@field right ow.Git.DiffSide ---@param worktree string ---@param path string ---@param content boolean ---@return ow.Git.DiffSide local function head_pane(worktree, path, content) return { buf = content and diff.git_show_buf(worktree, "HEAD", path) or diff.empty_buf(), name = "git://HEAD/" .. path, } end ---@param worktree string ---@param path string ---@param exists boolean ---@return ow.Git.DiffSide local function worktree_pane(worktree, path, exists) if exists then return { buf = diff.load_file_buf(vim.fs.joinpath(worktree, path)), name = nil, } end return { buf = diff.empty_buf(), name = "git://worktree/" .. path } end ---@param s ow.Git.StatusState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide local function index_pane(s, entry) local in_index = not ( entry.section == "Untracked" or (entry.section == "Staged" and entry.x == "D") ) return { buf = in_index and diff.git_show_buf(s.worktree, "", entry.path, true) or diff.empty_buf(), name = "git://index/" .. entry.path, } end ---@param s ow.Git.StatusState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide? local function other_pane(s, entry) local p = entry.path local worktree = s.worktree if entry.section == "Staged" then if entry.x == "A" then return head_pane(worktree, p, false) end if entry.x == "D" then return head_pane(worktree, p, true) end -- HEAD holds the pre-rename path return head_pane(worktree, entry.orig or p, true) end if entry.section == "Unstaged" then return worktree_pane(worktree, p, entry.y ~= "D") end if entry.section == "Untracked" then return worktree_pane(worktree, p, true) end end ---@param s ow.Git.StatusState ---@param entry ow.Git.StatusEntry ---@return ow.Git.DiffPair? local function compute_pair(s, entry) if not entry.path then return nil end ---@cast entry ow.Git.FileEntry local other = other_pane(s, entry) if not other then return nil end return { left = index_pane(s, entry), right = other } end ---@param win integer local function reset_diff_win(win) vim.api.nvim_win_call(win, function() vim.cmd( "setlocal winfixwidth< number< relativenumber< signcolumn< wrap< cursorline<" ) end) end ---@param sidebar_win integer ---@return integer? local function find_default_main_win(sidebar_win) local non_sidebar = {} for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do if win ~= sidebar_win then table.insert(non_sidebar, win) end end if #non_sidebar ~= 1 then return nil end local buf = vim.api.nvim_win_get_buf(non_sidebar[1]) if vim.api.nvim_buf_get_name(buf) == "" and vim.bo[buf].buftype == "" and not vim.bo[buf].modified and vim.api.nvim_buf_line_count(buf) == 1 and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == "" then return non_sidebar[1] end end ---@param win integer ---@param enabled boolean local function set_diff(win, enabled) vim.api.nvim_win_call(win, function() vim.cmd(enabled and "diffthis" or "diffoff") end) if enabled then vim.wo[win].foldenable = true vim.wo[win].foldlevel = 0 end end ---@param s ow.Git.StatusState ---@param sidebar_win integer ---@return integer? left ---@return integer? right local function adopt_diff_wins(s, sidebar_win) local left = s.diff_left_win local right = s.diff_right_win if left and not vim.api.nvim_win_is_valid(left) then left = nil end if right and not vim.api.nvim_win_is_valid(right) then right = nil end if left and right then return left, right end for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do if win ~= sidebar_win then local role = vim.w[win].git_diff_role if role == "left" and not left then left = win elseif role == "right" and not right then right = win end end end return left, right end ---@param entry ow.Git.StatusEntry ---@return string local function entry_key(entry) return entry.section .. "|" .. (entry.path or entry.sha or "") .. "|" .. (entry.orig or "") end ---@param s ow.Git.StatusState ---@param entry ow.Git.StatusEntry ---@param focus_left boolean local function show_diff(s, entry, focus_left) local sidebar_win = find_sidebar() if not sidebar_win then return end local left_win, right_win = adopt_diff_wins(s, sidebar_win) local key = entry_key(entry) if s.last_shown_key == key and left_win and right_win then if focus_left then vim.api.nvim_set_current_win(left_win) else vim.api.nvim_set_current_win(sidebar_win) end return end local pair = compute_pair(s, entry) if not pair then return end if left_win and not right_win then vim.api.nvim_set_current_win(left_win) vim.cmd("rightbelow vertical split") right_win = vim.api.nvim_get_current_win() reset_diff_win(right_win) elseif right_win and not left_win then vim.api.nvim_set_current_win(right_win) vim.cmd("leftabove vertical split") left_win = vim.api.nvim_get_current_win() reset_diff_win(left_win) elseif not (left_win or right_win) then local default_main = find_default_main_win(sidebar_win) if default_main then right_win = default_main reset_diff_win(right_win) vim.api.nvim_set_current_win(default_main) vim.cmd("leftabove vertical split") left_win = vim.api.nvim_get_current_win() reset_diff_win(left_win) else -- No reusable default-empty window. Open the diff pair by -- splitting from the sidebar. winfixwidth keeps the sidebar at 50 -- when there are other windows to absorb the split; if the -- sidebar is the only window in the tab, the split has to take -- from the sidebar itself, so restore the width explicitly. vim.api.nvim_set_current_win(sidebar_win) vim.cmd("rightbelow vertical split") right_win = vim.api.nvim_get_current_win() reset_diff_win(right_win) vim.cmd("leftabove vertical split") left_win = vim.api.nvim_get_current_win() reset_diff_win(left_win) vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) end local combined = vim.api.nvim_win_get_width(left_win) + vim.api.nvim_win_get_width(right_win) vim.api.nvim_win_set_width(left_win, math.floor(combined / 2)) end assert(left_win and right_win, "diff windows must be set") vim.w[left_win].git_diff_role = "left" vim.w[right_win].git_diff_role = "right" s.diff_left_win = left_win s.diff_right_win = right_win vim.api.nvim_win_set_buf(left_win, pair.left.buf) vim.api.nvim_win_set_buf(right_win, pair.right.buf) for _, side in ipairs({ pair.left, pair.right }) do if side.name then pcall(vim.api.nvim_buf_set_name, side.buf, side.name) local ft = vim.filetype.match({ buf = side.buf }) if ft then vim.bo[side.buf].filetype = ft end end end set_diff(left_win, true) set_diff(right_win, true) s.last_shown_key = key if focus_left then vim.api.nvim_set_current_win(left_win) else vim.api.nvim_set_current_win(sidebar_win) end end ---@param focus_left boolean local function preview_or_open(focus_left) local s, entry = current_entry(vim.api.nvim_get_current_buf()) if not s or not entry then return end show_diff(s, entry, focus_left) end local function action_stage() local s, entry = current_entry(vim.api.nvim_get_current_buf()) if not s or not entry or not entry.path then return end ---@cast entry ow.Git.FileEntry if entry.section == "Staged" then return end vim.system({ "git", "add", "--", entry.path }, { cwd = s.worktree }) end local function action_unstage() local s, entry = current_entry(vim.api.nvim_get_current_buf()) if not s or not entry or not entry.path then return end ---@cast entry ow.Git.FileEntry if entry.section ~= "Staged" then return end local cmd = { "git", "restore", "--staged", "--" } if entry.orig then table.insert(cmd, entry.orig) end table.insert(cmd, entry.path) vim.system(cmd, { cwd = s.worktree }) end local function action_discard() local s, entry = current_entry(vim.api.nvim_get_current_buf()) if not s or not entry or not entry.path then return end ---@cast entry ow.Git.FileEntry if entry.section == "Staged" then log.warning("file has staged changes; unstage first with 'u'") return end local prompt, action if entry.section == "Untracked" then prompt = string.format("Delete untracked file %s?", entry.path) action = function() os.remove(vim.fs.joinpath(s.worktree, entry.path)) refresh(vim.api.nvim_get_current_buf()) end elseif entry.section == "Unstaged" then prompt = string.format("Discard changes to %s?", entry.path) action = function() vim.system( { "git", "checkout", "--", entry.path }, { cwd = s.worktree } ) end else return end if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then action() end end local function action_help() print(table.concat({ "git status sidebar", " preview diff (keep focus)", " open diff (focus left pane)", " s stage file", " u unstage file", " X discard worktree changes (untracked: delete file)", " g? show this help", }, "\n")) end ---@param worktree string local function open(worktree) local existing = find_sidebar() if existing then vim.api.nvim_set_current_win(existing) return end local gitdir = repo.resolve(worktree) if not gitdir then return end local previous_win = vim.api.nvim_get_current_win() vim.cmd("leftabove vertical new") local win = vim.api.nvim_get_current_win() local bufnr = vim.api.nvim_get_current_buf() vim.bo[bufnr].buftype = "nofile" vim.bo[bufnr].bufhidden = "wipe" vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = "gitstatus" vim.bo[bufnr].modifiable = false vim.wo[win].number = false vim.wo[win].relativenumber = false vim.wo[win].wrap = false vim.wo[win].signcolumn = "no" vim.wo[win].cursorline = true vim.wo[win].winfixwidth = true vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH) state[bufnr] = { gitdir = gitdir, worktree = worktree, lines = {} } local function k(lhs, rhs, desc) vim.keymap.set( "n", lhs, rhs, { buffer = bufnr, silent = true, desc = desc } ) end k("", function() preview_or_open(false) end, "Preview diff") k("", function() preview_or_open(true) end, "Open diff") k("s", action_stage, "Stage file") k("u", action_unstage, "Unstage file") k("X", action_discard, "Discard worktree changes") k("g?", action_help, "Help") state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", { pattern = "GitRefresh", group = group, callback = function(args) if args.data and args.data.gitdir == gitdir then refresh(bufnr) end end, }) vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = bufnr, group = group, callback = function() local s = state[bufnr] if not s then return end if s.user_aucmd then pcall(vim.api.nvim_del_autocmd, s.user_aucmd) end state[bufnr] = nil end, }) vim.api.nvim_set_current_win(previous_win) refresh(bufnr) end function M.toggle() local sidebar_win = find_sidebar() if sidebar_win then vim.api.nvim_win_close(sidebar_win, false) return end local path = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) if path == "" then path = vim.fn.getcwd() end local _, worktree = repo.resolve(path) if not worktree then log.warning("not in a git repository") return end open(worktree) end return M