local Revision = require("git.revision") local diff = require("git.diff") local object = require("git.object") local repo = require("git.repo") local status = require("git.status") local util = require("git.util") 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.SidebarEntry ow.Git.FileEntry | ow.Git.CommitEntry ---@class ow.Git.SidebarState ---@field gitdir string ---@field worktree string ---@field lines table ---@field sidebar_win integer? ---@field invocation_win integer? ---@field diff_left_win integer? ---@field diff_right_win integer? ---@field user_aucmd integer? ---@field last_shown_key string? ---@field last_render_key string? ---@type table local state = {} local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false }) local ns = vim.api.nvim_create_namespace("ow.git.sidebar") ---@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 == "gitsidebar" then return win, buf end end end ---@param s ow.Git.SidebarState ---@return integer? local function sidebar_win_for(s) local win = s.sidebar_win if win and vim.api.nvim_win_is_valid(win) then return win end win = find_sidebar() s.sidebar_win = win return win end ---@param entry ow.Git.SidebarEntry ---@return string? local function entry_code(entry) if entry.section == "Untracked" then return "??" elseif entry.section == "Unmerged" then return entry.x .. entry.y elseif entry.section == "Staged" then return entry.x .. " " elseif entry.section == "Unstaged" then return " " .. entry.y end end ---@param entry ow.Git.SidebarEntry ---@return string? line ---@return string? hl_group ---@return integer? hl_len 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 = status.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 ---@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 stdout string ---@return ow.Git.BranchInfo, table local function parse_porcelain(stdout) local branch = { ahead = 0, behind = 0 } local groups = { Untracked = {}, Unstaged = {}, Staged = {}, Unmerged = {}, Unpushed = {}, Unpulled = {}, } for line in stdout: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 if x == "R" or x == "C" or y == "R" or y == "C" then local arrow = rest:find(" -> ", 1, true) if arrow then orig = rest:sub(1, arrow - 1) rest = rest:sub(arrow + 4) end 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 status.UNMERGED[x .. y] then entry.section = "Unmerged" table.insert(groups.Unmerged, entry) else if x ~= " " then table.insert(groups.Staged, { section = "Staged", path = entry.path, orig = entry.orig, x = entry.x, y = entry.y, }) end if y ~= " " then table.insert(groups.Unstaged, { section = "Unstaged", path = entry.path, orig = entry.orig, x = entry.x, y = entry.y, }) end end end end return branch, groups end ---@param worktree string ---@param branch ow.Git.BranchInfo ---@param groups table local function enrich_with_log(worktree, branch, groups) 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 local pending = {} for _, f in ipairs(fetches) do table.insert(pending, { f = f, sys = vim.system({ "git", "log", "--max-count=200", "--format=%h %s", f.range, }, { cwd = worktree, text = true }), }) end for _, p in ipairs(pending) do local result = p.sys:wait() if result.code == 0 then for line in (result.stdout or ""):gmatch("[^\r\n]+") do local sha, subject = line:match("^(%S+)%s+(.+)$") if sha then table.insert(groups[p.f.section], { section = p.f.section, sha = sha, subject = subject, }) end end else util.error( "git log %s failed: %s", p.f.range, vim.trim(result.stderr or "") ) end end end ---@param worktree string ---@param prefetched_stdout string? ---@param callback fun(branch: ow.Git.BranchInfo, groups: table) local function fetch_status(worktree, prefetched_stdout, callback) if prefetched_stdout then local branch, groups = parse_porcelain(prefetched_stdout) enrich_with_log(worktree, branch, groups) callback(branch, groups) return end vim.system( { "git", "-c", "core.quotePath=false", "status", "--porcelain=v1", "--branch", }, { cwd = worktree, text = true }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error("git status failed: %s", vim.trim(obj.stderr or "")) local branch = { ahead = 0, behind = 0 } local groups = { Untracked = {}, Unstaged = {}, Staged = {}, Unmerged = {}, Unpushed = {}, Unpulled = {}, } callback(branch, groups) return end local branch, groups = parse_porcelain(obj.stdout or "") enrich_with_log(worktree, branch, groups) callback(branch, groups) 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 branch ow.Git.BranchInfo ---@param groups table ---@return string local function fingerprint(branch, groups) local parts = { branch.head or "", branch.upstream or "", tostring(branch.ahead), tostring(branch.behind), } for _, section in ipairs(SECTIONS) do local entries = groups[section] if entries then for _, e in ipairs(entries) do table.insert( parts, e.section .. ":" .. (e.path or e.sha or "") .. ":" .. (e.orig or "") .. ":" .. (e.x or "") .. ":" .. (e.y or "") ) end end end return table.concat(parts, "\0") end ---@param bufnr integer ---@param prefetched_stdout string? local function refresh(bufnr, prefetched_stdout) local s = state[bufnr] if not s then return end local saved_path, saved_sha local sidebar_win = sidebar_win_for(s) if sidebar_win then local lnum = vim.api.nvim_win_get_cursor(sidebar_win)[1] local entry = s.lines[lnum] if entry then saved_path = entry.path saved_sha = entry.sha end end fetch_status(s.worktree, prefetched_stdout, function(branch, groups) if not vim.api.nvim_buf_is_valid(bufnr) then return end s.last_shown_key = nil local fp = fingerprint(branch, groups) if fp == s.last_render_key then return end s.last_render_key = fp 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 local win = sidebar_win_for(s) if win then pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 }) end break end end end) end ---@param bufnr integer ---@return ow.Git.SidebarState? ---@return ow.Git.SidebarEntry? 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 ---@return ow.Git.DiffSide local function head_pane(worktree, path) local rev = Revision.new({ base = "HEAD", path = path }) return { buf = object.buf_for(worktree, rev), name = rev:uri(), } end ---@param worktree string ---@param path string ---@return ow.Git.DiffSide local function worktree_pane(worktree, path) local buf = vim.fn.bufadd(vim.fs.joinpath(worktree, path)) vim.fn.bufload(buf) return { buf = buf, name = nil } end ---@param s ow.Git.SidebarState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide local function index_pane(s, entry) local rev = Revision.new({ stage = 0, path = entry.path }) return { buf = object.buf_for(s.worktree, rev), name = rev:uri(), } end ---@param s ow.Git.SidebarState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide? local function older_pane(s, entry) if entry.section == "Staged" then if entry.x == "A" then return nil end return head_pane(s.worktree, entry.orig or entry.path) end if entry.section == "Unstaged" then return index_pane(s, entry) end return nil end ---@param s ow.Git.SidebarState ---@param entry ow.Git.FileEntry ---@return ow.Git.DiffSide? local function newer_pane(s, entry) if entry.section == "Staged" then if entry.x == "D" then return nil end return index_pane(s, entry) end if entry.section == "Unstaged" then if entry.y == "D" then return nil end return worktree_pane(s.worktree, entry.path) end if entry.section == "Untracked" then return worktree_pane(s.worktree, entry.path) end return nil 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 s ow.Git.SidebarState ---@return integer? local function invocation_win_for(s) local win = s.invocation_win if not win or not vim.api.nvim_win_is_valid(win) then return nil end if win == s.sidebar_win then return nil end if vim.api.nvim_win_get_tabpage(win) ~= vim.api.nvim_get_current_tabpage() then return nil end return win end ---@param s ow.Git.SidebarState ---@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.FileEntry ---@return string local function entry_key(entry) return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "") end ---@param target_win integer ---@param dir "left"|"right" ---@return integer local function vsplit_at(target_win, dir) local win = vim.api.nvim_open_win( vim.api.nvim_win_get_buf(target_win), true, { split = dir, win = target_win } ) vim.cmd.clearjumps() return win end ---@param s ow.Git.SidebarState ---@param sidebar_win integer ---@param right_win integer? ---@return integer local function ensure_right_win(s, sidebar_win, right_win) if right_win then return right_win end local target = invocation_win_for(s) if target then right_win = target else right_win = vsplit_at(sidebar_win, "right") vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) end reset_diff_win(right_win) return right_win end ---@param s ow.Git.SidebarState ---@param entry ow.Git.SidebarEntry ---@param focus_left boolean local function view_entry(s, entry, focus_left) if not entry.path then return end ---@cast entry ow.Git.FileEntry local sidebar_win = sidebar_win_for(s) if not sidebar_win then return end local left = older_pane(s, entry) local right = newer_pane(s, entry) if not left and not right then util.warning( "no content for %s entry: %s", string.lower(entry.section), entry.path ) return end local key = entry_key(entry) local left_win, right_win = adopt_diff_wins(s, sidebar_win) local want_pair = left and right if s.last_shown_key == key then local intact = (want_pair and left_win and right_win) or (not want_pair and right_win and not left_win) if intact then local target = focus_left and (left_win or right_win) or sidebar_win vim.api.nvim_set_current_win(target) return end end if not want_pair then if left_win and vim.api.nvim_win_is_valid(left_win) then pcall(vim.api.nvim_win_close, left_win, false) left_win = nil s.diff_left_win = nil end right_win = ensure_right_win(s, sidebar_win, right_win) s.diff_right_win = right_win vim.w[right_win].git_diff_role = "right" local side = left or right ---@cast side ow.Git.DiffSide diff.set_diff(right_win, false) vim.api.nvim_win_set_buf(right_win, side.buf) if side.name then util.set_buf_name(side.buf, side.name) end s.last_shown_key = key vim.api.nvim_set_current_win(focus_left and right_win or sidebar_win) return end ---@cast left ow.Git.DiffSide ---@cast right ow.Git.DiffSide if left_win and not right_win then right_win = vsplit_at(left_win, "right") reset_diff_win(right_win) elseif right_win and not left_win then left_win = vsplit_at(right_win, "left") reset_diff_win(left_win) elseif not (left_win or right_win) then right_win = ensure_right_win(s, sidebar_win, nil) left_win = vsplit_at(right_win, "left") reset_diff_win(left_win) 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 diff.update_pair(left_win, right_win, { left = left, right = right }) s.last_shown_key = key vim.api.nvim_set_current_win(focus_left and left_win or sidebar_win) 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 view_entry(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 }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error("git add failed: %s", vim.trim(obj.stderr or "")) end end) ) 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 }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error( "git restore --staged failed: %s", vim.trim(obj.stderr or "") ) end end) ) 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 util.warning("file has staged changes, unstage first with 'u'") return end local prompt, action if entry.section == "Untracked" then local is_dir = entry.path:sub(-1) == "/" prompt = string.format( "Delete untracked %s %s?", is_dir and "directory" or "file", entry.path ) action = function() local target = vim.fs.joinpath(s.worktree, entry.path) local rc = vim.fn.delete(target, is_dir and "rf" or "") if rc ~= 0 then util.error("failed to delete %s", entry.path) end 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 }, vim.schedule_wrap(function(obj) if obj.code ~= 0 then util.error( "git checkout failed: %s", vim.trim(obj.stderr or "") ) end end) ) 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() local bufnr, win = util.new_scratch({ split = "left" }) vim.bo[bufnr].filetype = "gitsidebar" 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 = {}, sidebar_win = win, invocation_win = previous_win, } 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, args.data.porcelain_stdout) 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 _, worktree = repo.resolve_cwd() if not worktree then util.warning("not in a git repository") return end open(worktree) end return M