diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 4fc9caa..302c64a 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -7,90 +7,13 @@ local M = {} ---@class ow.Git.Cmd.SplitHandler ---@field ft string ----@field needs_rev boolean? ----@field on_state? fun(state: ow.Git.Repo.BufState, r: ow.Git.Repo, args: string[]) - ----@param r ow.Git.Repo ----@param args string[] -- diff args including leading "diff" ----@return string left_ref ----@return string? right_ref -- nil means worktree -local function compute_diff_refs(r, args) - local cached = false - local positional = {} ---@type string[] - local saw_separator = false - for i = 2, #args do - local a = args[i] - if saw_separator then - break - elseif a == "--" then - saw_separator = true - elseif a == "--cached" or a == "--staged" then - cached = true - elseif a:sub(1, 1) ~= "-" then - table.insert(positional, a) - end - end - - local function defaults() - if cached then - return "HEAD", ":" - end - return ":", nil - end - - if #positional == 0 then - return defaults() - end - - local first = positional[1] --[[@as string]] - if #positional == 1 then - local lhs, rhs = first:match("^(.-)%.%.%.(.+)$") - if lhs then - return (lhs ~= "" and lhs or "HEAD"), rhs - end - lhs, rhs = first:match("^(.-)%.%.(.+)$") - if lhs then - return (lhs ~= "" and lhs or "HEAD"), - (rhs ~= "" and rhs or "HEAD") - end - if r:rev_parse(first, true) then - if cached then - return first, ":" - end - return first, nil - end - return defaults() - end - - local second = positional[2] --[[@as string]] - local first_ok = r:rev_parse(first, true) ~= nil - if first_ok and r:rev_parse(second, true) then - return first, second - end - if first_ok then - if cached then - return first, ":" - end - return first, nil - end - return defaults() -end ---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, - diff = { - ft = "gitdiff", - on_state = function(state, r, args) - local left, right = compute_diff_refs(r, args) - state.left_ref = left - state.right_ref = right - end, - }, + diff = { ft = "git" }, } -M._compute_diff_refs = compute_diff_refs - ---@type string[]? local cached_cmds @@ -207,28 +130,16 @@ function M.parse_args(line) return args end ----@param args string[] ----@param start integer ----@return string? -local function first_positional(args, start) - for i = start, #args do - local a = args[i] - if a:sub(1, 1) ~= "-" then - return a - end - end -end - ---@param name string ---@return integer buf local function place_split(name) - local buf = vim.fn.bufnr("\\V" .. name) - if buf == -1 then - buf = util.new_scratch({ name = name, bufhidden = "hide" }) - return buf - end + -- bufadd resolves the name the same way nvim_buf_set_name does + -- (cwd-prefixing for non-absolute names), so calling it twice with + -- the same name returns the same buffer. + local buf = vim.fn.bufadd(name) if not vim.api.nvim_buf_is_loaded(buf) then vim.fn.bufload(buf) + util.setup_scratch(buf, { bufhidden = "hide" }) end local win = vim.fn.bufwinid(buf) if win ~= -1 then @@ -278,9 +189,7 @@ end ---@param args string[] ---@param conf ow.Git.Cmd.SplitHandler local function run_in_split(r, args, conf) - local cmd = { "git" } - vim.list_extend(cmd, args) - util.exec(cmd, { + util.git(args, { cwd = r.worktree, on_done = function(stdout) if not stdout then @@ -291,19 +200,6 @@ local function run_in_split(r, args, conf) object.attach_dispatch(buf) attach_history_keys(buf) local state = r:state(buf) --[[@as -nil]] - state.sha = nil - state.parent_sha = nil - if conf.needs_rev then - local user_rev = first_positional(args, 2) or "HEAD" - local sha = r:rev_parse(user_rev, true) - if sha then - state.sha = sha - state.parent_sha = r:rev_parse(user_rev .. "^", true) - end - end - if conf.on_state then - conf.on_state(state, r, args) - end vim.bo[buf].filetype = conf.ft -- Force a new undo block so each rerun is its own undo step. vim.bo[buf].undolevels = vim.bo[buf].undolevels @@ -386,7 +282,7 @@ function M.run(args) object.open(r, args[2]) return end - run_in_split(r, args, { ft = "git", needs_rev = true }) + run_in_split(r, args, { ft = "git" }) return end @@ -395,7 +291,7 @@ function M.run(args) object.open(r, args[3]) return end - run_in_split(r, args, { ft = "git", needs_rev = true }) + run_in_split(r, args, { ft = "git" }) return end diff --git a/lua/git/commit.lua b/lua/git/commit.lua index 2b29cfd..584ff48 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -28,11 +28,13 @@ function M.commit(opts) f:close() end - local buf, win = util.new_scratch({ name = file_path }) + local buf, win = util.new_scratch({ + name = file_path, + buftype = "acwrite", + modifiable = true, + }) proxy_buf = buf proxy_win = win - vim.bo[buf].buftype = "acwrite" - vim.bo[buf].modifiable = true vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modified = false vim.bo[buf].filetype = "gitcommit" diff --git a/lua/git/log_view.lua b/lua/git/log_view.lua index 322fa9d..2ee59f2 100644 --- a/lua/git/log_view.lua +++ b/lua/git/log_view.lua @@ -97,10 +97,7 @@ function M.read_uri(buf) end repo.bind(buf, r) - vim.bo[buf].swapfile = false - vim.bo[buf].bufhidden = "delete" - vim.bo[buf].buftype = "nofile" - vim.bo[buf].modifiable = false + util.setup_scratch(buf, { bufhidden = "delete" }) if vim.bo[buf].filetype ~= "gitlog" then vim.bo[buf].filetype = "gitlog" end diff --git a/lua/git/object.lua b/lua/git/object.lua index c2255d5..6a5068e 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -182,8 +182,7 @@ local function populate(buf, r, rev, state, rev_sha) if rev.path == nil then local commit_sha = r:rev_parse(rev_str .. "^{commit}", true) if commit_sha then - local patch = util.exec({ - "git", + local patch = util.git({ "diff-tree", "-p", "-m", @@ -195,7 +194,6 @@ local function populate(buf, r, rev, state, rev_sha) if patch then stdout = (stdout:gsub("\n*$", "\n\n")) .. patch end - state.parent_sha = r:rev_parse(commit_sha .. "^", true) end end @@ -220,8 +218,12 @@ function M.read_uri(buf) repo.bind(buf, r) local state = r:state(buf) --[[@as -nil]] - vim.bo[buf].swapfile = false - vim.bo[buf].bufhidden = "delete" + 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 @@ -234,15 +236,9 @@ function M.read_uri(buf) state.immutable = is_immutable_rev(rev) - if rev.stage == 0 and rev.path then - vim.bo[buf].buftype = "acwrite" - if not state.index_writer then - attach_index_writer(buf, r, rev.path) - state.index_writer = true - end - else - vim.bo[buf].buftype = "nofile" - vim.bo[buf].modifiable = false + 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 @@ -283,35 +279,47 @@ local function refresh(buf, r) end end ----@param r ow.Git.Repo ----@param ref string? -- nil = worktree, ":" = index, else commit/sha +---@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 ----@param blob string? -- diff section blob hash; if zero, side has no content ---@return integer? -local function side_buf(r, ref, path, blob) - if blob and is_zero(blob) then +local function side_buf(r, blob, path) + if not blob or is_zero(blob) then return nil end - if ref == nil then - local p = vim.fs.joinpath(r.worktree, path) - if not vim.uv.fs_stat(p) 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 - local rev = ref == ":" and Revision.new({ stage = 0, path = path }) - or Revision.new({ base = ref, path = path }) - return M.buf_for(r, rev) + return nil end ---@param r ow.Git.Repo ----@param ref string? ----@param path string ---@param blob string? -local function load_side(r, ref, path, blob) - local buf = side_buf(r, ref, path, blob) +---@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 @@ -321,16 +329,14 @@ local function load_side(r, ref, path, blob) end ---@param r ow.Git.Repo ----@param left_ref string? ----@param right_ref string? ---@param section ow.Git.DiffSection -local function open_section(r, left_ref, right_ref, section) +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, left_ref, section.path_a, section.blob_a) - local right = side_buf(r, right_ref, section.path_b, section.blob_b) + 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 require("git.diff").open(left, right, true) return @@ -377,36 +383,24 @@ function M.open_under_cursor() local line = vim.api.nvim_get_current_line() local r = s.repo - if s.sha and not s.left_ref then - 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 - local nav_rev = entry_type == "blob" - and Revision.new({ base = s.sha, path = entry_name }):format() - or entry_sha - M.open(r, nav_rev, { split = false }) - return true - end + 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 left_ref, right_ref - if s.left_ref then - left_ref = s.left_ref - right_ref = s.right_ref - elseif s.sha then - left_ref = s.parent_sha or "0" - right_ref = s.sha - else - return false + 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() @@ -415,23 +409,23 @@ function M.open_under_cursor() end if line:match("^diff %-%-git ") then - open_section(r, left_ref, right_ref, section) + open_section(r, section) return true end if line:match("^%-%-%- ") then - load_side(r, left_ref, section.path_a, section.blob_a) + load_side(r, section.blob_a, section.path_a) return true end if line:match("^%+%+%+ ") then - load_side(r, right_ref, section.path_b, section.blob_b) + load_side(r, section.blob_b, section.path_b) return true end local prefix = line:sub(1, 1) if prefix == "+" then - load_side(r, right_ref, section.path_b, section.blob_b) + load_side(r, section.blob_b, section.path_b) return true elseif prefix == "-" then - load_side(r, left_ref, section.path_a, section.blob_a) + load_side(r, section.blob_a, section.path_a) return true end return false diff --git a/lua/git/repo.lua b/lua/git/repo.lua index 7b3adfd..b6140e7 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -15,9 +15,6 @@ end ---@class ow.Git.Repo.BufState ---@field repo ow.Git.Repo ---@field sha string? ----@field parent_sha string? ----@field left_ref string? ----@field right_ref string? ---@field initialized boolean? ---@field immutable boolean? ---@field index_writer boolean? @@ -318,6 +315,30 @@ function Repo:rev_parse(rev, short) return trimmed ~= "" and trimmed or nil end +---@alias ow.Git.Repo.ResolveStatus "ok"|"ambiguous"|"missing" + +---@param prefix string +---@return string? full_sha +---@return ow.Git.Repo.ResolveStatus +function Repo:resolve_sha(prefix) + local result = self:get_cached("resolve:" .. prefix, function(self) + local out = util.exec( + { "git", "rev-parse", "--disambiguate=" .. prefix }, + { cwd = self.worktree, silent = true } + ) + local trimmed = out and vim.trim(out) or "" + if trimmed == "" then + return { nil, "missing" } + end + local lines = util.split_lines(trimmed) + if #lines == 1 then + return { lines[1], "ok" } + end + return { nil, "ambiguous" } + end) + return result[1], result[2] +end + ---@type table keyed by worktree local repos = {} diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index 4638708..a1ad66c 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -754,10 +754,7 @@ function M.read_uri(buf) end repo.bind(buf, r) - vim.bo[buf].buftype = "nofile" - vim.bo[buf].swapfile = false - vim.bo[buf].modifiable = false - vim.bo[buf].bufhidden = "hide" + util.setup_scratch(buf, { bufhidden = "hide" }) vim.bo[buf].filetype = "gitstatus" ---@type integer? diff --git a/lua/git/util.lua b/lua/git/util.lua index c0b3f6f..02888b6 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -2,16 +2,19 @@ local M = {} ---@class ow.Git.Util.ScratchOpts ---@field name string? ----@field bufhidden ("hide"|"wipe")? +---@field bufhidden ("hide"|"wipe"|"delete")? +---@field buftype ("nofile"|"acwrite"|"nowrite")? +---@field modifiable boolean? ---@param buf integer ---@param opts ow.Git.Util.ScratchOpts -local function setup_scratch(buf, opts) - vim.bo[buf].buftype = "nofile" +function M.setup_scratch(buf, opts) + vim.bo[buf].buftype = opts.buftype or "nofile" vim.bo[buf].bufhidden = opts.bufhidden or "wipe" vim.bo[buf].swapfile = false - vim.bo[buf].modifiable = false + vim.bo[buf].modifiable = opts.modifiable == true vim.bo[buf].modified = false + vim.bo[buf].buflisted = false if opts.name then pcall(vim.api.nvim_buf_set_name, buf, opts.name) end @@ -52,7 +55,7 @@ end function M.new_scratch(opts) opts = opts or {} local buf = vim.api.nvim_create_buf(false, true) - setup_scratch(buf, opts) + M.setup_scratch(buf, opts) return buf, M.place_buf(buf, opts.split) end @@ -188,6 +191,15 @@ end ---@field silent boolean? ---@field on_done fun(stdout: string?)? +---@param args string[] +---@param opts ow.Git.Util.ExecOpts? +---@return string? +function M.git(args, opts) + local cmd = { "git" } + vim.list_extend(cmd, args) + return M.exec(cmd, opts) +end + ---@param cmd string[] ---@param opts ow.Git.Util.ExecOpts? ---@return string? diff --git a/syntax/gitdiff.vim b/syntax/gitdiff.vim deleted file mode 100644 index a23f9f3..0000000 --- a/syntax/gitdiff.vim +++ /dev/null @@ -1,7 +0,0 @@ -if exists("b:current_syntax") - finish -endif - -runtime! syntax/git.vim - -let b:current_syntax = "gitdiff" diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua index e0dc665..28efd6e 100644 --- a/test/git/cmd_test.lua +++ b/test/git/cmd_test.lua @@ -234,90 +234,3 @@ t.test("complete unknown subcommand falls back to tracked paths", function() local matches = cmd.complete("", "G nonexistent ", 14) eq_sorted(matches, { "a", "b" }) end) - -t.test("compute_diff_refs default is index vs worktree", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff" }) - t.eq(left, ":") - t.eq(right, nil) -end) - -t.test("compute_diff_refs --cached is HEAD vs index", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff", "--cached" }) - t.eq(left, "HEAD") - t.eq(right, ":") -end) - -t.test("compute_diff_refs --staged is HEAD vs index", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff", "--staged" }) - t.eq(left, "HEAD") - t.eq(right, ":") -end) - -t.test("compute_diff_refs single rev is rev vs worktree", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD" }) - t.eq(left, "HEAD") - t.eq(right, nil) -end) - -t.test("compute_diff_refs single rev with --cached is rev vs index", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = - cmd._compute_diff_refs(r, { "diff", "--cached", "HEAD" }) - t.eq(left, "HEAD") - t.eq(right, ":") -end) - -t.test("compute_diff_refs two revs", function() - local dir = make_repo({ a = "x" }) - git(dir, "commit", "--allow-empty", "-m", "second") - local r = assert(require("git.repo").resolve(dir)) - local left, right = - cmd._compute_diff_refs(r, { "diff", "HEAD~1", "HEAD" }) - t.eq(left, "HEAD~1") - t.eq(right, "HEAD") -end) - -t.test("compute_diff_refs double-dot range", function() - local dir = make_repo({ a = "x" }) - git(dir, "commit", "--allow-empty", "-m", "second") - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff", "HEAD~1..HEAD" }) - t.eq(left, "HEAD~1") - t.eq(right, "HEAD") -end) - -t.test("compute_diff_refs triple-dot range", function() - local dir = make_repo({ a = "x" }) - git(dir, "commit", "--allow-empty", "-m", "second") - local r = assert(require("git.repo").resolve(dir)) - local left, right = - cmd._compute_diff_refs(r, { "diff", "HEAD~1...HEAD" }) - t.eq(left, "HEAD~1") - t.eq(right, "HEAD") -end) - -t.test("compute_diff_refs path-only falls back to defaults", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = cmd._compute_diff_refs(r, { "diff", "a" }) - t.eq(left, ":") - t.eq(right, nil) -end) - -t.test("compute_diff_refs ignores args after --", function() - local dir = make_repo({ a = "x" }) - local r = assert(require("git.repo").resolve(dir)) - local left, right = - cmd._compute_diff_refs(r, { "diff", "--", "HEAD" }) - t.eq(left, ":") - t.eq(right, nil) -end)