refactor(git): introduce Revision class, normalize naming, slim docs
This commit is contained in:
+77
-126
@@ -1,36 +1,30 @@
|
||||
local diff = require("git.diff")
|
||||
local Revision = require("git.revision")
|
||||
local repo = require("git.repo")
|
||||
local util = require("git.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ow.Git.DiffSection
|
||||
---@field pre_path string path on the parent side (`a/...`)
|
||||
---@field post_path string path on the current side (`b/...`)
|
||||
---@field pre_blob string?
|
||||
---@field post_blob string?
|
||||
---@field path_a string
|
||||
---@field path_b string
|
||||
---@field blob_a string?
|
||||
---@field blob_b string?
|
||||
|
||||
---@class ow.Git.BufContext
|
||||
---@field worktree string
|
||||
---@field ref string resolved commit SHA of the gitobject buffer
|
||||
---@field parent_ref string? resolved parent commit SHA, nil for root commits
|
||||
---@field sha string
|
||||
---@field parent_sha string?
|
||||
|
||||
---@return ow.Git.BufContext?
|
||||
local function context()
|
||||
local worktree = vim.b.git_worktree
|
||||
local ref = vim.b.git_ref
|
||||
if not worktree or not ref then
|
||||
local sha = vim.b.git_sha
|
||||
if not worktree or not sha then
|
||||
return nil
|
||||
end
|
||||
return { worktree = worktree, ref = ref, parent_ref = vim.b.git_parent_ref }
|
||||
return { worktree = worktree, sha = sha, parent_sha = vim.b.git_parent_sha }
|
||||
end
|
||||
|
||||
---Find the enclosing `diff --git` line and parse the section's pre/post
|
||||
---paths plus the pre/post blob SHAs from the `index` line.
|
||||
---
|
||||
---Uses `vim.fn.search('bcnW')` (backward, accept cursor pos, no move, no
|
||||
---wrap) so a giant `git show <merge>` buffer doesn't pay an O(cursor_lnum)
|
||||
---array allocation on every <CR>.
|
||||
---@return ow.Git.DiffSection?
|
||||
local function diff_section()
|
||||
local diff_lnum = vim.fn.search("^diff --git ", "bcnW")
|
||||
@@ -42,33 +36,30 @@ local function diff_section()
|
||||
if not diff_line then
|
||||
return nil
|
||||
end
|
||||
local pre_path, post_path = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
|
||||
if not pre_path or not post_path then
|
||||
local path_a, path_b = diff_line:match("^diff %-%-git a/(.-) b/(.+)$")
|
||||
if not path_a or not path_b then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Header lines (mode/index/oldfile/newfile/etc) sit between the
|
||||
-- `diff --git` line and the first `@@` hunk. Cap the read at 20 to
|
||||
-- bound work even for unusual diff headers.
|
||||
local header =
|
||||
vim.api.nvim_buf_get_lines(0, diff_lnum, diff_lnum + 20, false)
|
||||
local pre_blob, post_blob
|
||||
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
|
||||
pre_blob = pre
|
||||
post_blob = post
|
||||
blob_a = pre
|
||||
blob_b = post
|
||||
break
|
||||
end
|
||||
end
|
||||
return {
|
||||
pre_path = pre_path,
|
||||
post_path = post_path,
|
||||
pre_blob = pre_blob,
|
||||
post_blob = post_blob,
|
||||
path_a = path_a,
|
||||
path_b = path_b,
|
||||
blob_a = blob_a,
|
||||
blob_b = blob_b,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -78,9 +69,6 @@ local function is_zero(sha)
|
||||
return sha == nil or sha:match("^0+$") ~= nil
|
||||
end
|
||||
|
||||
---Stage-0 (`:<path>`) index entries are writable through the buffer:
|
||||
---`:w` rewrites the entry via `hash-object` + `update-index`. All other
|
||||
---revspecs (HEAD:, <sha>:, :1:, bare object refs) stay read-only.
|
||||
---@param buf integer
|
||||
---@param worktree string
|
||||
---@param path string
|
||||
@@ -115,9 +103,9 @@ local function attach_index_writer(buf, worktree, path)
|
||||
end
|
||||
vim.b[buf].git_index_mode = mode
|
||||
end
|
||||
-- Use the 3-arg form (mode sha path) instead of the comma form
|
||||
-- (mode,sha,path), which doesn't survive paths containing a
|
||||
-- comma.
|
||||
-- Use the 3-arg form (mode sha path) instead of the comma
|
||||
-- form (mode,sha,path), which doesn't survive paths
|
||||
-- containing a comma.
|
||||
if
|
||||
not util.exec({
|
||||
"git",
|
||||
@@ -135,18 +123,15 @@ local function attach_index_writer(buf, worktree, path)
|
||||
})
|
||||
end
|
||||
|
||||
---Pre-fetched content keyed by bufnr, consumed once by `read_uri`.
|
||||
---@type table<integer, string>
|
||||
local pending_content = {}
|
||||
|
||||
---Return a `git://<revspec>` URI buffer. Pass `content` to prime
|
||||
---`read_uri`'s cache and skip its `cat-file -p` fetch.
|
||||
---@param worktree string
|
||||
---@param revspec string any revspec git understands (e.g. `HEAD:foo`, `:foo`, `:1:foo`, `<sha>`, `<sha>:foo`)
|
||||
---@param rev ow.Git.Revision
|
||||
---@param content string?
|
||||
---@return integer
|
||||
function M.buf_for(worktree, revspec, content)
|
||||
local buf = vim.fn.bufadd(util.uri(revspec))
|
||||
function M.buf_for(worktree, rev, content)
|
||||
local buf = vim.fn.bufadd(rev:uri())
|
||||
vim.b[buf].git_worktree = worktree
|
||||
if content then
|
||||
pending_content[buf] = content
|
||||
@@ -155,17 +140,14 @@ function M.buf_for(worktree, revspec, content)
|
||||
return buf
|
||||
end
|
||||
|
||||
---BufReadCmd handler for `git://<revspec>` URIs. Worktree comes from
|
||||
---`b:git_worktree` if set, else from cwd. Stage-0 index entries
|
||||
---(`:<path>`) are made writable so `:w` updates the index. Other
|
||||
---revspecs are read-only.
|
||||
---@param buf integer
|
||||
function M.read_uri(buf)
|
||||
local name = vim.api.nvim_buf_get_name(buf)
|
||||
local revspec = util.parse_uri(name)
|
||||
if not revspec then
|
||||
local rev = Revision.from_uri(name)
|
||||
if not rev then
|
||||
return
|
||||
end
|
||||
local rev_str = rev:format()
|
||||
|
||||
local worktree = vim.b[buf].git_worktree or select(2, repo.resolve_cwd())
|
||||
if not worktree then
|
||||
@@ -182,25 +164,14 @@ function M.read_uri(buf)
|
||||
pending_content[buf] = nil
|
||||
if stdout == nil then
|
||||
stdout = util.exec(
|
||||
{ "git", "cat-file", "-p", revspec },
|
||||
{ "git", "cat-file", "-p", rev_str },
|
||||
{ cwd = worktree }
|
||||
)
|
||||
end
|
||||
|
||||
local parsed = util.parse_revspec(revspec)
|
||||
|
||||
-- Bare-ref objects that dereference to a commit (commits, stashes,
|
||||
-- annotated tags pointing at a commit, lightweight tags) get their
|
||||
-- `diff-tree -p` patch appended so the buffer is navigable. The
|
||||
-- `<CR>` parser walks `diff --git` blocks. `^{commit}` is git's
|
||||
-- standard "deref to commit" suffix. rev-parse fails for non-commit
|
||||
-- objects (trees, blobs, tags pointing at non-commits) so they
|
||||
-- naturally skip the append. `-m --first-parent` collapses merges
|
||||
-- and stashes into one diff per file (vs `diff --cc` combined
|
||||
-- diffs, which the parser can't follow).
|
||||
if stdout and parsed.path == nil then
|
||||
if stdout and rev.path == nil then
|
||||
local commit_sha =
|
||||
repo.rev_parse(worktree, revspec .. "^{commit}", true)
|
||||
repo.rev_parse(worktree, rev_str .. "^{commit}", true)
|
||||
if commit_sha then
|
||||
local patch = util.exec({
|
||||
"git",
|
||||
@@ -215,31 +186,25 @@ function M.read_uri(buf)
|
||||
if patch then
|
||||
stdout = (stdout:gsub("\n*$", "\n\n")) .. patch
|
||||
end
|
||||
vim.b[buf].git_parent_ref =
|
||||
vim.b[buf].git_parent_sha =
|
||||
repo.rev_parse(worktree, commit_sha .. "^", true)
|
||||
end
|
||||
end
|
||||
|
||||
if stdout then
|
||||
-- Reload paths (`:e`, `<C-o>` to an unloaded buf) re-enter
|
||||
-- with `modifiable = false` from the prior load.
|
||||
vim.bo[buf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, util.split_lines(stdout))
|
||||
end
|
||||
|
||||
-- `b:git_ref` anchors `<CR>`-driven navigation in this buffer.
|
||||
local ref_sha = repo.rev_parse(worktree, revspec, true)
|
||||
if ref_sha then
|
||||
vim.b[buf].git_ref = ref_sha
|
||||
local rev_sha = repo.rev_parse(worktree, rev_str, true)
|
||||
if rev_sha then
|
||||
vim.b[buf].git_sha = rev_sha
|
||||
end
|
||||
|
||||
if parsed.stage == 0 and parsed.path then
|
||||
if rev.stage == 0 and rev.path then
|
||||
vim.bo[buf].buftype = "acwrite"
|
||||
-- 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, parsed.path)
|
||||
attach_index_writer(buf, worktree, rev.path)
|
||||
vim.b[buf].git_index_writer = true
|
||||
end
|
||||
else
|
||||
@@ -248,15 +213,12 @@ function M.read_uri(buf)
|
||||
end
|
||||
vim.bo[buf].modified = false
|
||||
|
||||
-- Filetype from the inner path. We can't lean on `vim.filetype.add`
|
||||
-- because Vim normalises `git://` filenames (cwd-prefix + collapses
|
||||
-- `://` to `:/`) before matching, breaking any pattern keyed on the
|
||||
-- raw scheme as well as any built-in pattern that doesn't catch a
|
||||
-- recognisable extension on the mangled form (.Xresources is the
|
||||
-- canonical example). Bare-ref content (commit/tag headers + tree
|
||||
-- listings) uses the built-in `git` filetype.
|
||||
if parsed.path then
|
||||
local ft = vim.filetype.match({ filename = parsed.path, buf = buf })
|
||||
-- Match on the inner path directly. `vim.filetype.add` patterns
|
||||
-- don't work because Vim normalises `git://` filenames (cwd-prefix
|
||||
-- + `://` -> `:/`) before matching, breaking any pattern keyed on
|
||||
-- the raw scheme.
|
||||
if rev.path then
|
||||
local ft = vim.filetype.match({ filename = rev.path, buf = buf })
|
||||
if ft then
|
||||
vim.bo[buf].filetype = ft
|
||||
end
|
||||
@@ -264,34 +226,29 @@ function M.read_uri(buf)
|
||||
vim.bo[buf].filetype = "git"
|
||||
end
|
||||
|
||||
-- BufReadCmd suppresses the normal BufReadPost dispatch, so
|
||||
-- modeline parsing doesn't run unless we fire it ourselves. The
|
||||
-- modeline can still override the filetype set above (standard Vim
|
||||
-- precedence).
|
||||
vim.api.nvim_exec_autocmds("BufReadPost", { buffer = buf })
|
||||
end
|
||||
|
||||
---Buffer for the file at `<ref>:<path>`. Returns nil for a zero/nil blob.
|
||||
---@param worktree string
|
||||
---@param blob string?
|
||||
---@param path string
|
||||
---@param ref string the commit ref the blob represents (e.g. `<sha>` or `<sha>^`)
|
||||
---@param sha string
|
||||
---@return integer?
|
||||
local function blob_buf(worktree, blob, path, ref)
|
||||
local function blob_buf(worktree, blob, path, sha)
|
||||
if is_zero(blob) then
|
||||
return nil
|
||||
end
|
||||
return M.buf_for(worktree, ref .. ":" .. path)
|
||||
return M.buf_for(worktree, Revision.new({ base = sha, path = path }))
|
||||
end
|
||||
|
||||
---@param worktree string
|
||||
---@param blob string?
|
||||
---@param path string
|
||||
---@param ref string
|
||||
local function load_blob(worktree, blob, path, ref)
|
||||
local buf = blob_buf(worktree, blob, path, ref)
|
||||
---@param sha string
|
||||
local function load_blob(worktree, blob, path, sha)
|
||||
local buf = blob_buf(worktree, blob, path, sha)
|
||||
if not buf then
|
||||
util.warning("no content for %s at %s", path, ref)
|
||||
util.warning("no content for %s at %s", path, sha)
|
||||
return
|
||||
end
|
||||
vim.cmd.normal({ "m'", bang = true })
|
||||
@@ -301,21 +258,20 @@ end
|
||||
---@param ctx ow.Git.BufContext
|
||||
---@param section ow.Git.DiffSection
|
||||
local function open_section(ctx, section)
|
||||
if not section.pre_blob or not section.post_blob then
|
||||
if not section.blob_a or not section.blob_b then
|
||||
util.warning("no index line, cannot determine blob SHAs")
|
||||
return
|
||||
end
|
||||
local parent = ctx.parent_ref or "0"
|
||||
local left =
|
||||
blob_buf(ctx.worktree, section.pre_blob, section.pre_path, parent)
|
||||
local parent = ctx.parent_sha or "0"
|
||||
local left = blob_buf(ctx.worktree, section.blob_a, section.path_a, parent)
|
||||
local right =
|
||||
blob_buf(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
|
||||
blob_buf(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
|
||||
if left and right then
|
||||
diff.open(left, right, true)
|
||||
require("git.diff").open(left, right, true)
|
||||
return
|
||||
end
|
||||
if not left and not right then
|
||||
util.warning("no content for %s", section.post_path)
|
||||
util.warning("no content for %s", section.path_b)
|
||||
return
|
||||
end
|
||||
local buf = left or right
|
||||
@@ -325,36 +281,32 @@ local function open_section(ctx, section)
|
||||
end
|
||||
|
||||
---@class ow.Git.OpenObjectOpts
|
||||
---@field split (false|"above"|"below"|"left"|"right")? forwarded to `util.place_buf`. Default opens a new horizontal split.
|
||||
---@field split (false|"above"|"below"|"left"|"right")?
|
||||
|
||||
---Open any git object. Accepts a bare ref (commit / tree / blob / tag
|
||||
---sha, branch, tag name, `stash@{N}`, etc.) or `<commit-ref>:<path>`.
|
||||
---Resolves to a sha so the URI stays stable if the ref later moves.
|
||||
---@param worktree string
|
||||
---@param ref string
|
||||
---@param rev string
|
||||
---@param opts ow.Git.OpenObjectOpts?
|
||||
function M.open_object(worktree, ref, opts)
|
||||
local commit_ref, path = ref:match("^(.-):(.+)$")
|
||||
local revspec
|
||||
if commit_ref then
|
||||
local sha = repo.rev_parse(worktree, commit_ref, true) or commit_ref
|
||||
revspec = sha .. ":" .. path
|
||||
else
|
||||
revspec = repo.rev_parse(worktree, ref, true) or ref
|
||||
function M.open_object(worktree, rev, opts)
|
||||
local parsed = Revision.parse(rev)
|
||||
if parsed.base then
|
||||
local sha = repo.rev_parse(worktree, parsed.base, true)
|
||||
if sha then
|
||||
parsed.base = sha
|
||||
end
|
||||
end
|
||||
local content = util.exec(
|
||||
{ "git", "cat-file", "-p", revspec },
|
||||
{ "git", "cat-file", "-p", parsed:format() },
|
||||
{ cwd = worktree, silent = true }
|
||||
)
|
||||
if not content then
|
||||
util.warning("not a git object: %s", ref)
|
||||
util.warning("not a git object: %s", rev)
|
||||
return
|
||||
end
|
||||
local buf = M.buf_for(worktree, revspec, content)
|
||||
local buf = M.buf_for(worktree, parsed, content)
|
||||
util.place_buf(buf, opts and opts.split)
|
||||
end
|
||||
|
||||
---@return boolean dispatched true if the cursor was on an actionable line
|
||||
---@return boolean dispatched
|
||||
function M.open_under_cursor()
|
||||
local ctx = context()
|
||||
if not ctx then
|
||||
@@ -372,14 +324,13 @@ function M.open_under_cursor()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Blobs navigate by path so the URI carries the entry name (filetype
|
||||
-- detection wants the extension). Other types navigate by sha.
|
||||
local entry_type, entry_sha, entry_name =
|
||||
line:match("^%d+ (%w+) (%x+)\t(.+)$")
|
||||
if entry_sha then
|
||||
local nav_ref = entry_type == "blob" and (ctx.ref .. ":" .. entry_name)
|
||||
local nav_rev = entry_type == "blob"
|
||||
and Revision.new({ base = ctx.sha, path = entry_name }):format()
|
||||
or entry_sha
|
||||
M.open_object(ctx.worktree, nav_ref, { split = false })
|
||||
M.open_object(ctx.worktree, nav_rev, { split = false })
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -387,26 +338,26 @@ function M.open_under_cursor()
|
||||
if not section then
|
||||
return false
|
||||
end
|
||||
local parent = ctx.parent_ref or "0"
|
||||
local parent = ctx.parent_sha or "0"
|
||||
|
||||
if line:match("^diff %-%-git ") then
|
||||
open_section(ctx, section)
|
||||
return true
|
||||
end
|
||||
if line:match("^%-%-%- ") then
|
||||
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent)
|
||||
load_blob(ctx.worktree, section.blob_a, section.path_a, parent)
|
||||
return true
|
||||
end
|
||||
if line:match("^%+%+%+ ") then
|
||||
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
|
||||
load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
|
||||
return true
|
||||
end
|
||||
local prefix = line:sub(1, 1)
|
||||
if prefix == "+" then
|
||||
load_blob(ctx.worktree, section.post_blob, section.post_path, ctx.ref)
|
||||
load_blob(ctx.worktree, section.blob_b, section.path_b, ctx.sha)
|
||||
return true
|
||||
elseif prefix == "-" then
|
||||
load_blob(ctx.worktree, section.pre_blob, section.pre_path, parent)
|
||||
load_blob(ctx.worktree, section.blob_a, section.path_a, parent)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user