local Revision = require("git.core.revision") local repo = require("git.core.repo") local util = require("git.core.util") local M = {} M.URI_PREFIX = "git://" ---@param rev ow.Git.Revision ---@return string function M.format_uri(rev) return M.URI_PREFIX .. rev:format() end ---@param str string ---@return ow.Git.Revision? function M.parse_uri(str) local raw = str:match("^" .. M.URI_PREFIX .. "(.+)$") if raw then return Revision.parse(raw) end end ---@class ow.Git.DiffSection ---@field path_a string ---@field path_b string ---@field blob_a string? ---@field blob_b string? ---@return ow.Git.DiffSection? local function diff_section() local diff_lnum = vim.fn.search("^diff --git ", "bcnW") if diff_lnum == 0 then return nil end local diff_line = vim.api.nvim_buf_get_lines(0, diff_lnum - 1, diff_lnum, false)[1] if not diff_line then return nil end local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$") if not path_a or not path_b then return nil end local header = vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false) local blob_a, blob_b for _, l in ipairs(header) do if l:sub(1, 3) == "@@ " or l:match("^diff %-%-git ") then break end local pre, post = l:match("^index (%x+)%.%.(%x+)") if pre then blob_a = pre blob_b = post break end end return { path_a = path_a, path_b = path_b, blob_a = blob_a, blob_b = blob_b, } end ---@param sha string? ---@return boolean local function is_zero(sha) return sha == nil or sha:match("^0+$") ~= nil end ---@param rev ow.Git.Revision ---@return boolean local function is_immutable_rev(rev) if rev.stage ~= nil then return false end local base = rev.base if not base then return false end local stripped = base:gsub("%^%b{}", ""):gsub("[%^~]%d*", "") return stripped:match("^%x+$") ~= nil and #stripped >= 7 end ---@param buf integer ---@param r ow.Git.Repo ---@param path string local function attach_index_writer(buf, r, path) vim.api.nvim_create_autocmd("BufWriteCmd", { buffer = buf, callback = function() local body = table.concat( vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n" ) .. "\n" local hash_stdout = util.git( { "hash-object", "-w", "--stdin" }, { cwd = r.worktree, stdin = body } ) if not hash_stdout then return end local sha = vim.trim(hash_stdout) local state = r:state(buf) local mode = state and state.index_mode if not mode then mode = "100644" local ls = util.git( { "ls-files", "-s", "--", path }, { cwd = r.worktree, silent = true } ) if ls then local m = ls:match("^(%d+)") if m then mode = m end end if state then state.index_mode = mode end end if not util.git({ "update-index", "--cacheinfo", mode, sha, path, }, { cwd = r.worktree }) then return end if state then state.sha = r:rev_parse(":" .. path, true) end vim.bo[buf].modified = false end, }) end local cr = vim.api.nvim_replace_termcodes("", true, false, true) ---@param buf integer function M.attach_dispatch(buf) vim.keymap.set("n", "", function() if not M.open_under_cursor() then vim.api.nvim_feedkeys(cr, "n", false) end end, { buffer = buf, silent = true, desc = "Open file at commit" }) end ---@param r ow.Git.Repo ---@param rev ow.Git.Revision ---@return integer function M.buf_for(r, rev) local buf = vim.fn.bufadd(M.format_uri(rev)) repo.bind(buf, r) vim.fn.bufload(buf) return buf end ---@param buf integer ---@param r ow.Git.Repo ---@param rev ow.Git.Revision ---@param state ow.Git.Repo.BufState ---@param rev_sha string ---@return boolean ok local function populate(buf, r, rev, state, rev_sha) local rev_str = rev:format() local stdout = util.git({ "cat-file", "-p", rev_str }, { cwd = r.worktree }) if not stdout then return false end if rev.path == nil then local commit_sha = r:rev_parse(rev_str .. "^{commit}", true) if commit_sha then local patch = util.git({ "diff-tree", "-p", "-m", "--first-parent", "--root", "--no-commit-id", commit_sha, }, { cwd = r.worktree }) if patch then stdout = (stdout:gsub("\n*$", "\n\n")) .. patch end end end util.set_buf_lines(buf, 0, -1, util.split_lines(stdout)) state.sha = rev_sha return true end ---@param buf integer function M.read_uri(buf) local name = vim.api.nvim_buf_get_name(buf) local rev = M.parse_uri(name) if not rev then return end local r = repo.resolve(buf) if not r then util.error("git BufReadCmd %s: cannot resolve worktree", name) return end repo.bind(buf, r) local state = r:state(buf) --[[@as -nil]] local writable = rev.stage == 0 and rev.path ~= nil util.setup_scratch(buf, { bufhidden = "delete", buftype = writable and "acwrite" or "nofile", modifiable = writable, }) local rev_sha = r:rev_parse(rev:format(), true) if not rev_sha then return end if not populate(buf, r, rev, state, rev_sha) then return end state.immutable = is_immutable_rev(rev) if writable and not state.index_writer then attach_index_writer(buf, r, rev.path --[[@as string]]) state.index_writer = true end if rev.path then local ft = vim.filetype.match({ filename = rev.path, buf = buf }) if ft then vim.bo[buf].filetype = ft end else vim.bo[buf].filetype = "git" end M.attach_dispatch(buf) vim.api.nvim_buf_call(buf, function() vim.api.nvim_exec_autocmds("BufReadPost", { buf = buf }) end) end ---@param buf integer ---@param r ow.Git.Repo local function refresh(buf, r) local state = r:state(buf) if not state or state.immutable or vim.bo[buf].modified then return end local rev = M.parse_uri(vim.api.nvim_buf_get_name(buf)) if not rev then return end local rev_sha = r:rev_parse(rev:format(), true) if not rev_sha or rev_sha == state.sha then return end if state.sha == nil then M.read_uri(buf) else populate(buf, r, rev, state, rev_sha) end end ---@param buf integer ---@param path string local function set_ft_from_path(buf, path) local ft = vim.filetype.match({ filename = path, buf = buf }) if ft then vim.bo[buf].filetype = ft end end ---@param r ow.Git.Repo ---@param blob string? ---@param path string ---@return integer? local function side_buf(r, blob, path) if not blob or is_zero(blob) then return nil end local full, status = r:resolve_sha(blob) if status == "ambiguous" then util.error("ambiguous blob abbreviation: %s", blob) return nil end if full then local buf = M.buf_for(r, Revision.new({ base = full })) set_ft_from_path(buf, path) return buf end local p = vim.fs.joinpath(r.worktree, path) if vim.uv.fs_stat(p) then local buf = vim.fn.bufadd(p) vim.fn.bufload(buf) return buf end return nil end ---@param r ow.Git.Repo ---@param blob string? ---@param path string local function load_side(r, blob, path) local buf = side_buf(r, blob, path) if not buf then util.error("no content for %s", path) return end vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(buf) end ---@param r ow.Git.Repo ---@param section ow.Git.DiffSection local function open_section(r, section) if not section.blob_a or not section.blob_b then util.error("no index line, cannot determine blob SHAs") return end local left = side_buf(r, section.blob_a, section.path_a) local right = side_buf(r, section.blob_b, section.path_b) if left and right then vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(right) require("git.diff").split({ target = vim.api.nvim_buf_get_name(left), mods = { vertical = true }, }) return end if not left and not right then util.error("no content for %s", section.path_b) return end local buf = left or right ---@cast buf -nil vim.cmd.normal({ "m'", bang = true }) vim.api.nvim_set_current_buf(buf) end ---@class ow.Git.Object.OpenOpts ---@field split (false|"above"|"below"|"left"|"right")? ---@param r ow.Git.Repo ---@param rev string ---@param opts ow.Git.Object.OpenOpts? function M.open(r, rev, opts) local parsed = Revision.parse(rev) if parsed.base then local sha = r:rev_parse(parsed.base, false) if not sha then util.error("not a git object: %s", rev) return end parsed.base = sha end if parsed.path and not r:rev_parse(parsed:format(), false) then util.error("not a git object: %s", rev) return end local buf = M.buf_for(r, parsed) util.place_buf(buf, opts and opts.split) end ---@return boolean dispatched function M.open_under_cursor() local r = repo.resolve() if not r then return false end local line = vim.api.nvim_get_current_line() local sha = line:match("^commit (%x+)$") or line:match("^parent (%x+)$") or line:match("^tree (%x+)$") or line:match("^object (%x+)$") if sha then M.open(r, sha, { split = false }) return true end local entry_type, entry_sha, entry_name = line:match("^%d+ (%w+) (%x+)\t(.+)$") if entry_sha then if entry_type == "blob" then load_side(r, entry_sha, entry_name --[[@as string]]) else M.open(r, entry_sha, { split = false }) end return true end local section = diff_section() if not section then return false end if line:match("^diff %-%-git ") then open_section(r, section) return true end if line:match("^%-%-%- ") then load_side(r, section.blob_a, section.path_a) return true end if line:match("^%+%+%+ ") then load_side(r, section.blob_b, section.path_b) return true end local prefix = line:sub(1, 1) if prefix == "+" then load_side(r, section.blob_b, section.path_b) return true elseif prefix == "-" then load_side(r, section.blob_a, section.path_a) return true end return false end repo.on_uri_change(M.URI_PREFIX, refresh) return M