diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index c30da6d..656b76f 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -68,10 +68,39 @@ local function first_positional(args, start) end end +---Open `:` in a split via the `git://` BufReadCmd loader. +---Resolves to a sha first so the URI stays stable if the ref moves. +---@param worktree string +---@param user_ref string +---@param path string +local function show_file_in_split(worktree, user_ref, path) + repo.rev_parse(worktree, user_ref, true, function(sha) + local label = sha or user_ref + local uri = "git://" .. label .. "//" .. path + local buf = vim.fn.bufadd(uri) + vim.b[buf].git_worktree = worktree + vim.cmd("split " .. vim.fn.fnameescape(uri)) + end) +end + ---@param worktree string ---@param args string[] ---@param conf ow.Git.SplitHandler local function run_in_split(worktree, args, conf) + -- `:` is a file lookup; the URI must carry the path so + -- filetype detection has something to match against. + if args[1] == "show" then + local arg = first_positional(args, 2) + if arg then + local ref, path = arg:match("^(.-):(.+)$") + if ref then + ---@cast path -nil + show_file_in_split(worktree, ref, path) + return + end + end + end + local buf = git.new_scratch() vim.b[buf].git_worktree = worktree if conf.needs_ref then @@ -80,7 +109,7 @@ local function run_in_split(worktree, args, conf) if not vim.api.nvim_buf_is_valid(buf) then return end - pcall(vim.api.nvim_buf_set_name, buf, "git://" .. label .. "/") + pcall(vim.api.nvim_buf_set_name, buf, "git://" .. label .. "//") vim.bo[buf].filetype = conf.ft end repo.rev_parse(worktree, user_ref, true, function(sha) diff --git a/lua/git/diff.lua b/lua/git/diff.lua index 5710ea6..adec5eb 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -59,33 +59,58 @@ local function attach_index_writer(buf, worktree, path) }) end ----Internal builder: create a scratch buffer and fill it with the output of ----`git show `. Synchronous so the buffer is ready by the time the ----caller wires up windows / `:diffthis`. An empty buffer briefly visible ----to the diff engine produces a spurious whole-file diff. +---Return a buffer holding the content at `:`. `ref` is +---"index", "HEAD", or a commit revspec. ---@param worktree string ----@param revspec string ----@param is_index boolean ----@param index_path string? required when is_index is true +---@param ref string +---@param path string ---@return integer -local function build_show_buf(worktree, revspec, is_index, index_path) - local buf = vim.api.nvim_create_buf(false, true) - vim.bo[buf].buftype = "nofile" - vim.bo[buf].bufhidden = "wipe" - vim.bo[buf].swapfile = false +function M.git_show_buf(worktree, ref, path) + local name = "git://" .. ref .. "//" .. path + local buf = vim.fn.bufadd(name) + vim.b[buf].git_worktree = worktree + vim.fn.bufload(buf) + return buf +end +---BufReadCmd handler for `git:////` URIs. Worktree comes from +---`vim.b[buf].git_worktree` if set, else from cwd. Ref of "index" maps +---to `git show :`; "worktree" leaves the buffer empty (placeholder +---for missing files); anything else is a revspec. +---@param buf integer +function M.read_uri(buf) + local name = vim.api.nvim_buf_get_name(buf) + local ref, path = name:match("^git://(.-)//(.*)$") + if not ref or path == "" then + return + end + ---@cast path -nil + + local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd()) + if not worktree then + log.error("git BufReadCmd %s: cannot resolve worktree", name) + return + end + vim.b[buf].git_worktree = worktree + + vim.bo[buf].swapfile = false + vim.bo[buf].bufhidden = "wipe" + + if ref == "worktree" then + vim.bo[buf].buftype = "nofile" + vim.bo[buf].modifiable = false + vim.bo[buf].modified = false + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) + return + end + + local revspec = ref == "index" and (":" .. path) or (ref .. ":" .. path) local result = vim.system( { "git", "show", revspec }, { cwd = worktree, text = true } ) :wait() - if result.code ~= 0 then - log.error( - "git show %s failed: %s", - revspec, - vim.trim(result.stderr or "") - ) - else + if result.code == 0 then vim.api.nvim_buf_set_lines( buf, 0, @@ -93,45 +118,51 @@ local function build_show_buf(worktree, revspec, is_index, index_path) false, util.split_lines(result.stdout or "") ) + else + log.error( + "git show %s failed: %s", + revspec, + vim.trim(result.stderr or "") + ) end - if is_index then + if ref == "index" then vim.bo[buf].buftype = "acwrite" - attach_index_writer(buf, worktree, assert(index_path)) + -- Re-running BufReadCmd (e.g. on `:edit`) would otherwise stack + -- another BufWriteCmd on the same buffer, so each `:w` runs + -- hash-object + update-index N times. + if not vim.b[buf].git_index_writer then + attach_index_writer(buf, worktree, path) + vim.b[buf].git_index_writer = true + end else + vim.bo[buf].buftype = "nofile" vim.bo[buf].modifiable = false end vim.bo[buf].modified = false - return buf -end ----@param worktree string ----@param ref string '' for index, 'HEAD' or a sha for committed refs ----@param path string ----@param is_index boolean? true to hook :w to update the git index ----@return integer -function M.git_show_buf(worktree, ref, path, is_index) - return build_show_buf(worktree, ref .. ":" .. path, is_index or false, path) -end - ----@param worktree string ----@param blob string the blob SHA (full or abbreviated) ----@return integer -function M.git_show_blob(worktree, blob) - return build_show_buf(worktree, blob, false, nil) + -- BufReadCmd suppresses the normal BufReadPost dispatch, so filetype + -- detection and modeline parsing don't run unless we fire it ourselves. + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf }) end ---@class ow.Git.EmptyBufOpts ---@field name string? ---@field bufhidden ("hide"|"wipe")? defaults to "wipe" ----Build a read-only scratch buffer, optionally naming it via ----`nvim_buf_set_name` (silently no-op if a buffer with that name ----already exists). +---Build a read-only scratch buffer, optionally naming it. When `opts.name` +---is set and a loaded buffer with that name already exists, returns it +---instead of creating a duplicate. ---@param opts ow.Git.EmptyBufOpts? ---@return integer function M.empty_buf(opts) opts = opts or {} + if opts.name then + local existing = vim.fn.bufnr(opts.name) + if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then + return existing + end + end local buf = vim.api.nvim_create_buf(false, true) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = opts.bufhidden or "wipe" @@ -215,30 +246,15 @@ function M.split(opts) return end - local is_index = opts.ref == "" - local other = M.git_show_buf(worktree, opts.ref, rel, is_index) - local label = is_index and "index" or opts.ref - M.set_buf_name_and_filetype(other, "git://" .. label .. "/" .. rel) + local label = opts.ref == "" and "index" or opts.ref + local uri = "git://" .. label .. "//" .. rel + -- Stash the worktree on the buffer so the BufReadCmd handler doesn't + -- fall back to cwd resolution (wrong when cwd != worktree). + local buf = vim.fn.bufadd(uri) + vim.b[buf].git_worktree = worktree - local cur_win = vim.api.nvim_get_current_win() - local other_win = vim.api.nvim_open_win(other, true, { - split = opts.vertical and "left" or "above", - win = cur_win, - }) - - -- The synthetic index/HEAD buffer can't run BufRead, so its filetype - -- detection in `set_buf_name_and_filetype` only catches - -- filename-pattern matches. Mirror cur_buf's filetype, since this is - -- the same logical file at a different version. Done after the - -- window opens so any BufWinEnter / BufEnter autocmds that fire on - -- nvim_open_win can't undo it. - local cur_ft = vim.bo[cur_buf].filetype - if cur_ft ~= "" then - vim.bo[other].filetype = cur_ft - end - - M.set_diff(cur_win, true) - M.set_diff(other_win, true) + local prefix = opts.vertical and "leftabove vert " or "leftabove " + vim.cmd(prefix .. "diffsplit " .. vim.fn.fnameescape(uri)) end return M diff --git a/lua/git/init.lua b/lua/git/init.lua index 33bef2a..75b0f9f 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -58,12 +58,19 @@ function M.setup() end vim.filetype.add({ pattern = { - ["git://[^/]+/(.+)"] = function(_, bufnr, inner) + ["git://.-//(.+)"] = function(_, bufnr, inner) return vim.filetype.match({ filename = inner, buf = bufnr }) end, }, }) local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "git://*", + group = group, + callback = function(args) + require("git.diff").read_uri(args.buf) + end, + }) vim.api.nvim_create_autocmd( { "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" }, { diff --git a/lua/git/show.lua b/lua/git/show.lua index 804ea39..c19ff91 100644 --- a/lua/git/show.lua +++ b/lua/git/show.lua @@ -80,30 +80,19 @@ local function is_zero(sha) return sha == nil or sha:match("^0+$") ~= nil end ----Build a buffer holding the file's content at a given blob, named after the ----commit ref it corresponds to (so the name lines up with `git log` output ----instead of an opaque blob hash). +---Buffer for the file at `:`. A zero/nil blob (file absent on +---this side of the diff) yields an empty placeholder. ---@param worktree string ---@param blob string? ---@param path string ---@param ref string the commit ref the blob represents (e.g. `` or `^`) ---@return integer local function blob_buf(worktree, blob, path, ref) - local name = "git://" .. ref .. "/" .. path - -- Reuse an existing buffer with this name (commit refs and blobs are - -- immutable, so the content is stable); avoids E95 collisions and - -- prevents accumulating duplicate buffers as the user navigates. - local existing = vim.fn.bufnr(name) - if existing ~= -1 and vim.api.nvim_buf_is_loaded(existing) then - return existing - end + local name = "git://" .. ref .. "//" .. path if is_zero(blob) then return diff.empty_buf({ name = name, bufhidden = "hide" }) end - ---@cast blob string - local buf = diff.git_show_blob(worktree, blob) - diff.set_buf_name_and_filetype(buf, name) - return buf + return diff.git_show_buf(worktree, ref, path) end ---@param worktree string @@ -143,7 +132,7 @@ end function M.open_commit(worktree, ref) repo.rev_parse(worktree, ref, true, function(resolved) local sha = resolved or ref - local name = "git://" .. sha .. "/" + local name = "git://" .. sha .. "//" -- Reuse a previously-opened buffer for the same commit; commit SHAs -- are immutable so the content is stable. local existing = vim.fn.bufnr(name) diff --git a/lua/git/sidebar.lua b/lua/git/sidebar.lua index 918bfca..37003f8 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/sidebar.lua @@ -485,14 +485,14 @@ end local function head_pane(worktree, path) return { buf = diff.git_show_buf(worktree, "HEAD", path), - name = "git://HEAD/" .. path, + name = "git://HEAD//" .. path, } end ---@param path string ---@return ow.Git.DiffSide local function head_empty_pane(path) - return { buf = diff.empty_buf(), name = "git://HEAD/" .. path } + return { buf = diff.empty_buf(), name = "git://HEAD//" .. path } end ---@param worktree string @@ -508,7 +508,7 @@ end ---@param path string ---@return ow.Git.DiffSide local function worktree_empty_pane(path) - return { buf = diff.empty_buf(), name = "git://worktree/" .. path } + return { buf = diff.empty_buf(), name = "git://worktree//" .. path } end ---@param s ow.Git.SidebarState @@ -520,9 +520,9 @@ local function index_pane(s, entry) or (entry.section == "Staged" and entry.x == "D") ) return { - buf = in_index and diff.git_show_buf(s.worktree, "", entry.path, true) + buf = in_index and diff.git_show_buf(s.worktree, "index", entry.path) or diff.empty_buf(), - name = "git://index/" .. entry.path, + name = "git://index//" .. entry.path, } end @@ -727,15 +727,6 @@ local function show_diff(s, entry, focus_left) diff.set_buf_name_and_filetype(side.buf, side.name) end end - -- Synthetic index/HEAD buffers never run BufRead, so modeline-only - -- filetypes aren't detected. Copy from the side that did resolve. - local left_ft = vim.bo[pair.left.buf].filetype - local right_ft = vim.bo[pair.right.buf].filetype - if left_ft == "" and right_ft ~= "" then - vim.bo[pair.left.buf].filetype = right_ft - elseif right_ft == "" and left_ft ~= "" then - vim.bo[pair.right.buf].filetype = left_ft - end diff.set_diff(left_win, true) diff.set_diff(right_win, true) s.last_shown_key = key