From 6bb5f7a68b7e4f5ce4397f4189f903d6fad1bacd Mon Sep 17 00:00:00 2001
From: Barrett Ruth
Date: Sun, 8 Nov 2026 15:54:15 -0600
Subject: [PATCH 1/2] refactor: remove unused functions and enable unused-code
warnings
---
.luarc.json | 6 +++-
lua/diffs/commands.lua | 7 ----
lua/diffs/conflict.lua | 5 ---
lua/diffs/difftastic.lua | 7 ----
lua/diffs/lists.lua | 39 ---------------------
lua/diffs/rails.lua | 15 --------
lua/diffs/review.lua | 14 --------
lua/diffs/runtime/init.lua | 18 ----------
lua/diffs/spec.lua | 35 -------------------
lua/diffs/split.lua | 18 ----------
spec/commands_spec.lua | 71 +++-----------------------------------
spec/conflict_spec.lua | 8 ++++-
spec/diffargs_spec.lua | 7 ++--
spec/diffspec_spec.lua | 22 +-----------
spec/jj_conflict_spec.lua | 16 +++++++--
spec/rails_spec.lua | 7 +++-
spec/read_buffer_spec.lua | 4 ++-
17 files changed, 45 insertions(+), 254 deletions(-)
diff --git a/.luarc.json b/.luarc.json
index 693e84b6..a919946a 100644
--- a/.luarc.json
+++ b/.luarc.json
@@ -17,5 +17,9 @@
"workspace.checkThirdParty": false,
"diagnostics.libraryFiles": "Disable",
"workspace.ignoreDir": [".direnv"],
- "completion.callSnippet": "Replace"
+ "completion.callSnippet": "Replace",
+ "diagnostics.severity": {
+ "unused-function": "Warning",
+ "unused-local": "Warning"
+ }
}
diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua
index 93c9f31a..d114c974 100644
--- a/lua/diffs/commands.lua
+++ b/lua/diffs/commands.lua
@@ -687,13 +687,6 @@ function M.review(spec, opts)
return review.open(spec, review_deps(opts))
end
----@param buf integer
----@param lnum integer
----@return string?
-function M.review_file_at_line(buf, lnum)
- return review.file_at_line(buf, lnum)
-end
-
local layout_options = {
'++layout=unified',
'++layout=stacked',
diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua
index eb0b36f1..c93f54e8 100644
--- a/lua/diffs/conflict.lua
+++ b/lua/diffs/conflict.lua
@@ -408,9 +408,4 @@ function M.attach(bufnr)
emit('DiffsConflictDetected', bufnr)
end
----@return integer
-function M.get_namespace()
- return ns
-end
-
return M
diff --git a/lua/diffs/difftastic.lua b/lua/diffs/difftastic.lua
index 5570ac61..0b912a2a 100644
--- a/lua/diffs/difftastic.lua
+++ b/lua/diffs/difftastic.lua
@@ -33,13 +33,6 @@ function M.clear_active(bufnr)
pcall(vim.api.nvim_buf_clear_namespace, bufnr, unified_ns, 0, -1)
end
----@param bufnr integer
----@return boolean
-function M.is_active(bufnr)
- local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, active_var)
- return ok and value == true
-end
-
---@return { enabled: boolean, args: string[] }
function M.resolve()
local raw = runtime.get_difftastic_config()
diff --git a/lua/diffs/lists.lua b/lua/diffs/lists.lua
index 2a70a91c..b76d6ec7 100644
--- a/lua/diffs/lists.lua
+++ b/lua/diffs/lists.lua
@@ -302,15 +302,6 @@ local function entry_at_lnum(entries, lnum, sections)
return entries[1]
end
----@param hunks diffs.DiffHunk[]
----@param entries table[]
----@param lnum integer
----@return string?
-local function file_at_lnum(hunks, entries, lnum)
- local entry = entry_at_lnum(entries or file_entries(hunks), lnum)
- return entry and entry.file or nil
-end
-
---@param bufnr integer
---@param hunks diffs.DiffHunk[]
---@param entry table?
@@ -842,18 +833,6 @@ function M.generated_hunks(bufnr)
return state and state.hunks or {}
end
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
----@return diffs.GeneratedFileSelection?
-function M.first_generated_file(diff_lines, opts)
- local state = list_state(diff_lines, opts)
- local entry = state.entries[1]
- if not entry then
- return nil
- end
- return selection_from_entry(state, entry)
-end
-
---@param diff_lines string[]
---@param opts? diffs.GeneratedListOptions
---@return diffs.GeneratedFileSelection[]
@@ -866,23 +845,6 @@ function M.generated_files(diff_lines, opts)
return files
end
----@param bufnr integer
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
-function M.set_loclist_for_buffer(bufnr, diff_lines, opts)
- opts = opts or {}
- local state = list_state(diff_lines, {
- diff_spec = opts.diff_spec or buffer_diff_spec(bufnr),
- title = opts.title,
- loclist_title = opts.loclist_title,
- })
- local title = state.loclist_title
- local entry = state.entries[1]
- for _, win in ipairs(windows_for_buffer(bufnr)) do
- set_loclist(win, title, loclist_items(bufnr, state.hunks, entry))
- end
-end
-
---@param hunk diffs.DiffHunk
---@param anchors integer[]?
---@return integer
@@ -988,7 +950,6 @@ function M.set_for_split_pair(opts)
end
M._test = {
- file_at_lnum = file_at_lnum,
file_entries = file_entries,
hunk_at_lnum = hunk_at_lnum,
loclist_items = loclist_items,
diff --git a/lua/diffs/rails.lua b/lua/diffs/rails.lua
index 46621bad..085578ca 100644
--- a/lua/diffs/rails.lua
+++ b/lua/diffs/rails.lua
@@ -184,21 +184,6 @@ function M.strip(line, prefix_width)
return stripped
end
----@param lines string[]
----@param prefix_width integer?
----@return string[]
-function M.strip_lines(lines, prefix_width)
- if type(prefix_width) ~= 'number' or prefix_width <= 0 then
- return lines
- end
-
- local stripped = {}
- for i, line in ipairs(lines) do
- stripped[i] = M.strip(line, prefix_width)
- end
- return stripped
-end
-
---@param prefix_width integer?
---@return diffs.RailRanges?
function M.ranges(prefix_width)
diff --git a/lua/diffs/review.lua b/lua/diffs/review.lua
index 64308127..deae3425 100644
--- a/lua/diffs/review.lua
+++ b/lua/diffs/review.lua
@@ -698,20 +698,6 @@ function M.open(spec, deps)
return diff_buf
end
----@param buf integer
----@param lnum integer
----@return string?
-function M.file_at_line(buf, lnum)
- local lines = vim.api.nvim_buf_get_lines(buf, 0, lnum, false)
- for i = #lines, 1, -1 do
- local file = lines[i]:match('^diff %-%-git %a/.+ %a/(.+)$')
- if file then
- return file
- end
- end
- return nil
-end
-
---@param review_spec diffs.ReviewSpec?
---@param repo_root string
---@param deps diffs.ReviewDeps
diff --git a/lua/diffs/runtime/init.lua b/lua/diffs/runtime/init.lua
index 8b96048c..cee4d42b 100644
--- a/lua/diffs/runtime/init.lua
+++ b/lua/diffs/runtime/init.lua
@@ -154,24 +154,6 @@ function M.get_fugitive_config()
return config.integrations.fugitive == true
end
----@return boolean
-function M.get_neojj_config()
- init()
- return config.integrations.neojj == true
-end
-
----@return boolean
-function M.get_committia_config()
- init()
- return config.integrations.committia == true
-end
-
----@return boolean
-function M.get_telescope_config()
- init()
- return config.integrations.telescope == true
-end
-
---@return boolean
function M.get_conflict_config()
init()
diff --git a/lua/diffs/spec.lua b/lua/diffs/spec.lua
index de278eef..08057c5b 100644
--- a/lua/diffs/spec.lua
+++ b/lua/diffs/spec.lua
@@ -180,13 +180,6 @@ function M.rev_to_rev(left_rev, right_rev, path)
return M.file(M.tree(left_rev), M.tree(right_rev), path)
end
----@param stage 1|2|3
----@param path string
----@return diffs.DiffSpec
-function M.stage_to_worktree(stage, path)
- return M.file(M.stage(stage), M.worktree(), path)
-end
-
---@param endpoint diffs.Endpoint
---@return string
function M.endpoint_label(endpoint)
@@ -256,32 +249,4 @@ function M.patch_actions(diff_spec)
}
end
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_index_target(diff_spec)
- return M.mutation_target(diff_spec) == M.endpoint_kind.index
-end
-
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_worktree_target(diff_spec)
- return M.mutation_target(diff_spec) == M.endpoint_kind.worktree
-end
-
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_read_only(diff_spec)
- return M.mutation_target(diff_spec) == nil
-end
-
----@param diff_spec diffs.DiffSpec
----@return { read_only: boolean, target: "index"|"worktree"|nil }
-function M.mutability(diff_spec)
- local target = M.mutation_target(diff_spec)
- return {
- read_only = target == nil,
- target = target,
- }
-end
-
return M
diff --git a/lua/diffs/split.lua b/lua/diffs/split.lua
index 7f8e32c1..e90de1a5 100644
--- a/lua/diffs/split.lua
+++ b/lua/diffs/split.lua
@@ -1424,24 +1424,6 @@ function M.goto_prev(bufnr)
goto_anchor(bufnr, -1)
end
----@param bufnr integer
----@param hunk_index integer?
----@return boolean
-function M.move_pair_to_hunk_index(bufnr, hunk_index)
- if type(hunk_index) ~= 'number' then
- return false
- end
-
- local info = pane_info[bufnr]
- local row = info and info.anchors and info.anchors[hunk_index]
- if not row then
- return false
- end
-
- move_pair_to_row(bufnr, row)
- return true
-end
-
---@param bufnr integer
function M.sync_cursor_to_hunk(bufnr)
local win = current_or_first_window_for_buffer(bufnr)
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
index a1b4c754..d86890ea 100644
--- a/spec/commands_spec.lua
+++ b/spec/commands_spec.lua
@@ -307,7 +307,9 @@ local function buffer_lines(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local ok, rail_width = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_width')
if ok then
- return rails.strip_lines(lines, rail_width)
+ return vim.tbl_map(function(line)
+ return rails.strip(line, rail_width)
+ end, lines)
end
return lines
end
@@ -2096,7 +2098,7 @@ describe('commands', function()
table.insert(test_buffers, diff_buf)
assert.are.equal('diffs://stage2:file.txt', vim.api.nvim_buf_get_name(diff_buf))
assert.are.same(
- diffspec.stage_to_worktree(2, 'file.txt'),
+ diffspec.file(diffspec.stage(2), diffspec.worktree(), 'file.txt'),
vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
)
assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
@@ -4438,71 +4440,6 @@ describe('commands', function()
end)
end)
- describe('review_file_at_line', function()
- local review_test_buffers = {}
-
- after_each(function()
- for _, bufnr in ipairs(review_test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- review_test_buffers = {}
- end)
-
- it('returns filename at cursor line', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local x = 1',
- ' return M',
- })
-
- assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 6))
- end)
-
- it('returns correct file in multi-file diff', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'diff --git a/foo.lua b/foo.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- 'diff --git a/bar.lua b/bar.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- })
-
- assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 3))
- assert.are.equal('bar.lua', commands.review_file_at_line(bufnr, 7))
- end)
-
- it('returns nil before any diff header', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'some preamble text',
- 'diff --git a/foo.lua b/foo.lua',
- })
-
- assert.is_nil(commands.review_file_at_line(bufnr, 1))
- end)
-
- it('returns nil on empty buffer', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
-
- assert.is_nil(commands.review_file_at_line(bufnr, 1))
- end)
- end)
-
describe('find_hunk_line', function()
it('finds matching @@ header and returns target line', function()
local diff_lines = {
diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua
index 176337f5..2a2733d2 100644
--- a/spec/conflict_spec.lua
+++ b/spec/conflict_spec.lua
@@ -8,7 +8,13 @@ local function create_file_buffer(lines)
end
local function get_extmarks(bufnr)
- return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
+ return vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ vim.api.nvim_get_namespaces()['diffs-conflict'],
+ 0,
+ -1,
+ { details = true }
+ )
end
describe('conflict', function()
diff --git a/spec/diffargs_spec.lua b/spec/diffargs_spec.lua
index 53d11d58..77eb3ab6 100644
--- a/spec/diffargs_spec.lua
+++ b/spec/diffargs_spec.lua
@@ -98,14 +98,17 @@ describe('diffs.diffargs', function()
for _, stage in ipairs({ 1, 2, 3 }) do
local result = parse((':%d:%%'):format(stage))
- assert.are.same(diffspec.stage_to_worktree(stage, path), result.spec)
+ assert.are.same(diffspec.file(diffspec.stage(stage), diffspec.worktree(), path), result.spec)
end
end)
it('maps merge-stage explicit-path objects to stage -> worktree of that path', function()
local result = parse(':3:lua/bar.lua')
- assert.are.same(diffspec.stage_to_worktree(3, 'lua/bar.lua'), result.spec)
+ assert.are.same(
+ diffspec.file(diffspec.stage(3), diffspec.worktree(), 'lua/bar.lua'),
+ result.spec
+ )
end)
it('pins explicit-path objects to the worktree even in a staged context', function()
diff --git a/spec/diffspec_spec.lua b/spec/diffspec_spec.lua
index bfd14949..dc3a4033 100644
--- a/spec/diffspec_spec.lua
+++ b/spec/diffspec_spec.lua
@@ -14,10 +14,6 @@ describe('diffs.spec', function()
scope = { kind = 'file', path = path },
mode = 'unified',
}, spec)
- assert.is_true(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'worktree' }, diffspec.mutability(spec))
assert.are.same({ can_put = true, can_obtain = false }, diffspec.patch_actions(spec))
assert.are.equal('index -> worktree file:lua/foo.lua', diffspec.label(spec))
end)
@@ -31,10 +27,6 @@ describe('diffs.spec', function()
scope = { kind = 'file', path = path },
mode = 'unified',
}, spec)
- assert.is_true(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'index' }, diffspec.mutability(spec))
assert.are.same({ can_put = false, can_obtain = true }, diffspec.patch_actions(spec))
assert.are.equal('tree:HEAD -> index file:lua/foo.lua', diffspec.label(spec))
end)
@@ -48,10 +40,6 @@ describe('diffs.spec', function()
scope = { kind = 'file', path = path },
mode = 'unified',
}, spec)
- assert.is_true(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'worktree' }, diffspec.mutability(spec))
assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
assert.are.equal('tree:feature~2 -> worktree file:lua/foo.lua', diffspec.label(spec))
end)
@@ -65,16 +53,12 @@ describe('diffs.spec', function()
scope = { kind = 'file', path = path },
mode = 'unified',
}, spec)
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_true(diffspec.is_read_only(spec))
- assert.are.same({ read_only = true }, diffspec.mutability(spec))
assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
assert.are.equal('tree:origin/main -> tree:HEAD file:lua/foo.lua', diffspec.label(spec))
end)
it('represents read-only merge-stage to worktree file diffs', function()
- local spec = diffspec.stage_to_worktree(2, path)
+ local spec = diffspec.file(diffspec.stage(2), diffspec.worktree(), path)
assert.are.same({
left = { kind = 'stage', stage = 2 },
@@ -82,10 +66,6 @@ describe('diffs.spec', function()
scope = { kind = 'file', path = path },
mode = 'unified',
}, spec)
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_true(diffspec.is_read_only(spec))
- assert.are.same({ read_only = true }, diffspec.mutability(spec))
assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
assert.are.equal('stage:2 -> worktree file:lua/foo.lua', diffspec.label(spec))
end)
diff --git a/spec/jj_conflict_spec.lua b/spec/jj_conflict_spec.lua
index b1b60007..d6ca7cdc 100644
--- a/spec/jj_conflict_spec.lua
+++ b/spec/jj_conflict_spec.lua
@@ -107,7 +107,13 @@ describe('jj conflict markers', function()
local rows = {}
for _, mark in
ipairs(
- vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
+ vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ vim.api.nvim_get_namespaces()['diffs-conflict'],
+ 0,
+ -1,
+ { details = true }
+ )
)
do
if mark[4] and mark[4].hl_group == 'DiffsConflictMarker' then
@@ -137,7 +143,13 @@ describe('jj conflict markers', function()
conflict.attach(bufnr)
assert.are.equal(
0,
- #vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, {})
+ #vim.api.nvim_buf_get_extmarks(
+ bufnr,
+ vim.api.nvim_get_namespaces()['diffs-conflict'],
+ 0,
+ -1,
+ {}
+ )
)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
diff --git a/spec/rails_spec.lua b/spec/rails_spec.lua
index 44727f6b..463e539e 100644
--- a/spec/rails_spec.lua
+++ b/spec/rails_spec.lua
@@ -101,7 +101,12 @@ describe('diffs.rails', function()
assert.are.equal(' 2 │', annotated[4])
assert.is_nil(annotated[4]:match('%s$'))
assert.are.equal(' ', rails.strip(annotated[4], info.prefix_width))
- assert.are.same(lines, rails.strip_lines(annotated, info.prefix_width))
+ assert.are.same(
+ lines,
+ vim.tbl_map(function(line)
+ return rails.strip(line, info.prefix_width)
+ end, annotated)
+ )
end)
it('returns byte ranges for old and new rail number columns', function()
diff --git a/spec/read_buffer_spec.lua b/spec/read_buffer_spec.lua
index 9429f3f9..edcc7df5 100644
--- a/spec/read_buffer_spec.lua
+++ b/spec/read_buffer_spec.lua
@@ -121,7 +121,9 @@ end
local function buffer_lines(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- return rails.strip_lines(lines, rails.width_for_buffer(bufnr))
+ return vim.tbl_map(function(line)
+ return rails.strip(line, rails.width_for_buffer(bufnr))
+ end, lines)
end
local function review_diff_lines()
From 25d8e033a2ba95fdfa4be4d1574e9cb3e1ea8774 Mon Sep 17 00:00:00 2001
From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com>
Date: Sun, 8 Nov 2026 16:00:57 -0600
Subject: [PATCH 2/2] refactor!: separate highlighting from collection
workflows (#485)
---
README.md | 153 +-
doc/diffs.nvim.txt | 1568 +-------
lua/diffs/actions.lua | 476 ---
lua/diffs/commands.lua | 2467 -------------
lua/diffs/config.lua | 19 -
lua/diffs/conflict.lua | 416 ---
lua/diffs/content.lua | 86 -
lua/diffs/diff.lua | 2 +-
lua/diffs/diffargs.lua | 239 --
lua/diffs/diffopt.lua | 25 -
lua/diffs/difftastic.lua | 400 ---
lua/diffs/fugitive.lua | 284 --
lua/diffs/generated/init.lua | 1 -
lua/diffs/generated/source.lua | 323 --
lua/diffs/git.lua | 233 --
lua/diffs/health.lua | 16 +-
lua/diffs/highlight.lua | 149 +-
lua/diffs/hunks.lua | 590 ---
lua/diffs/init.lua | 32 +-
lua/diffs/keymaps.lua | 86 -
lua/diffs/lists.lua | 999 ------
lua/diffs/merge.lua | 263 --
lua/diffs/parser.lua | 31 +-
lua/diffs/rails.lua | 288 --
lua/diffs/render.lua | 348 --
lua/diffs/review.lua | 735 ----
lua/diffs/runtime/attach.lua | 120 +-
lua/diffs/runtime/cache.lua | 38 +-
lua/diffs/runtime/decorator.lua | 20 +-
lua/diffs/runtime/diff_windows.lua | 38 +-
lua/diffs/runtime/highlight_groups.lua | 74 +-
lua/diffs/runtime/init.lua | 67 +-
lua/diffs/spec.lua | 287 --
lua/diffs/split.lua | 1460 --------
lua/diffs/split_align.lua | 118 -
plugin/diffs.lua | 138 +-
spec/actions_spec.lua | 735 ----
spec/api_spec.lua | 113 +
spec/commands_spec.lua | 4561 ------------------------
spec/config_spec.lua | 7 -
spec/conflict_spec.lua | 715 ----
spec/content_spec.lua | 71 -
spec/diff_files_spec.lua | 242 --
spec/diffargs_spec.lua | 181 -
spec/diffopt_spec.lua | 87 +-
spec/diffspec_spec.lua | 132 -
spec/difftastic_spec.lua | 219 --
spec/fugitive_spec.lua | 391 --
spec/git_spec.lua | 167 -
spec/highlight_spec.lua | 481 +--
spec/hunks_spec.lua | 383 --
spec/integration_spec.lua | 27 +-
spec/jj_conflict_spec.lua | 163 -
spec/merge_spec.lua | 792 ----
spec/parser_spec.lua | 85 +-
spec/plugin_spec.lua | 188 +-
spec/rails_spec.lua | 154 -
spec/read_buffer_spec.lua | 660 ----
spec/render_spec.lua | 311 --
spec/review_spec.lua | 177 -
spec/runtime_spec.lua | 65 -
spec/split_align_spec.lua | 109 -
spec/ux_spec.lua | 230 --
63 files changed, 518 insertions(+), 23517 deletions(-)
delete mode 100644 lua/diffs/actions.lua
delete mode 100644 lua/diffs/commands.lua
delete mode 100644 lua/diffs/conflict.lua
delete mode 100644 lua/diffs/content.lua
delete mode 100644 lua/diffs/diffargs.lua
delete mode 100644 lua/diffs/difftastic.lua
delete mode 100644 lua/diffs/fugitive.lua
delete mode 100644 lua/diffs/generated/init.lua
delete mode 100644 lua/diffs/generated/source.lua
delete mode 100644 lua/diffs/git.lua
delete mode 100644 lua/diffs/hunks.lua
delete mode 100644 lua/diffs/keymaps.lua
delete mode 100644 lua/diffs/lists.lua
delete mode 100644 lua/diffs/merge.lua
delete mode 100644 lua/diffs/rails.lua
delete mode 100644 lua/diffs/render.lua
delete mode 100644 lua/diffs/review.lua
delete mode 100644 lua/diffs/spec.lua
delete mode 100644 lua/diffs/split.lua
delete mode 100644 lua/diffs/split_align.lua
delete mode 100644 spec/actions_spec.lua
create mode 100644 spec/api_spec.lua
delete mode 100644 spec/commands_spec.lua
delete mode 100644 spec/conflict_spec.lua
delete mode 100644 spec/content_spec.lua
delete mode 100644 spec/diff_files_spec.lua
delete mode 100644 spec/diffargs_spec.lua
delete mode 100644 spec/diffspec_spec.lua
delete mode 100644 spec/difftastic_spec.lua
delete mode 100644 spec/fugitive_spec.lua
delete mode 100644 spec/git_spec.lua
delete mode 100644 spec/hunks_spec.lua
delete mode 100644 spec/jj_conflict_spec.lua
delete mode 100644 spec/merge_spec.lua
delete mode 100644 spec/rails_spec.lua
delete mode 100644 spec/read_buffer_spec.lua
delete mode 100644 spec/render_spec.lua
delete mode 100644 spec/review_spec.lua
delete mode 100644 spec/split_align_spec.lua
delete mode 100644 spec/ux_spec.lua
diff --git a/README.md b/README.md
index 4a574d19..ceb5f8bf 100644
--- a/README.md
+++ b/README.md
@@ -1,125 +1,78 @@
# diffs.nvim
-**Treesitter-powered Diff Syntax highlighting for Neovim**
+Language syntax highlighting for unified diffs in Neovim.
-Enhance Neovim's built-in diff mode (and much more!) with language-aware syntax
-highlighting driven by treesitter.
+Diffs uses Treesitter or Vim syntax to highlight the code inside diff hunks.
+It adds line backgrounds and intra-line highlights, and adjusts colors in native
+diff windows.
-## Features
-
-- Treesitter syntax highlighting in
- [vim-fugitive](https://github.com/tpope/vim-fugitive),
- [Neogit](https://github.com/NeogitOrg/neogit), builtin `diff` filetype, and
- more!
-- Word- and character-level diff highlighting, driven by `'diffopt'` `inline:`
-- `:Diff` for [pierre-style](https://diffs.com) unified, stacked, or split diffs against any revision
-- `:Diff review` full-repo review diff with qflist/loclist navigation
-- `:Diff files` to diff two arbitrary files, or the buffer against its last save
-- Inline and 3-way merge conflict detection, highlighting, and resolution
-- Email quoting/patch syntax support (`> diff ...`)
-- Vim syntax fallback
-- [Difftastic](https://difftastic.wilfred.me.uk/) highlight support
-- Configurable highlighting
-
## Requirements
- Neovim 0.12.0+
-- Optional: the Treesitter `diff` parser for the best experience
+- Optional: Treesitter parsers for the languages in your diffs and the `diff` parser
## Installation
-With `vim.pack` (Neovim 0.12+):
+```lua
+vim.pack.add({ 'https://github.com/barrettruth/diffs.nvim' })
+```
+
+Diffs attaches to `git` and `gitcommit` buffers by default. Enable integrations
+before the plugin loads:
+
+```lua
+vim.g.diffs = {
+ integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'committia', 'telescope' },
+}
+```
+
+To highlight patch files with the `diff` filetype:
```lua
-vim.pack.add({
- 'https://github.com/barrettruth/diffs.nvim',
+vim.api.nvim_create_autocmd('FileType', {
+ pattern = 'diff',
+ callback = function(event)
+ require('diffs').attach(event.buf)
+ end,
})
```
-## Documentation
+Use `'diffopt'` to select intra-line highlighting:
```vim
-:help diffs.nvim
+set diffopt+=inline:word
```
-## FAQ
+See `:help diffs.nvim` for configuration and the Lua API. Run `:checkhealth diffs`
+for dependency information.
-**Q: Does diffs.nvim support
-[vim-fugitive](https://github.com/tpope/vim-fugitive)/[Neogit](https://github.com/NeogitOrg/neogit)/[neojj](https://github.com/NicholasZolton/neojj)/[gitsigns](https://github.com/lewis6991/gitsigns.nvim)/[fzf-lua](https://github.com/ibhagwan/fzf-lua)?**
+## Diff commands and conflicts
-Yes. Enable integrations in your config:
+[diffs-collection.nvim](https://github.com/barrettruth/diffs-collection.nvim)
+contains file comparison, repository review, split views, quickfix navigation,
+and conflict resolution. Install it alongside diffs.nvim for these workflows.
-```lua
-vim.g.diffs = {
- integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns' },
-}
-```
+## Limitations
+
+Treesitter parses each hunk separately. Hunks that start or end inside an
+expression can have incomplete syntax highlighting.
+
+Vim syntax fallback runs after redraw. A new hunk can appear without syntax
+colors for one frame. Language parsers and queries also load on first use.
+
+Other plugins that change diff highlights can produce conflicting colors.
+
+## Acknowledgements
-fzf-lua is supported out-of-the-box.
-
-See the documentation for more information.
-
-**Q: Can I use diffs.nvim as a Git mergetool?**
-
-Yes. Configure Git to open `$MERGED` with Neovim; diffs.nvim will detect
-conflict markers automatically. See `:help diffs.nvim-git-mergetool`.
-
-**Q: Can I exclude untracked files from `:Diff review`?**
-
-Yes. Run `:Diff review ++nountracked`. To make it the default, wrap it in your
-own command, e.g. `:command! Review Diff review ++nountracked`.
-
-## Known Limitations
-
-- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.
- Hunks that start or end mid-expression may produce imperfect highlights due to
- treesitter error recovery.
-
-- **Syntax "flashing"**: `diffs.nvim` hooks into the `FileType fugitive` event
- triggered by `vim-fugitive`, at which point the buffer is preliminarily
- painted. The decoration provider applies highlights on the next redraw cycle,
- so a brief first-paint flash may still occur.
-
-- **Cold Start**: Treesitter grammar loading (~10ms) and query compilation
- (~4ms) are one-time costs per language per Neovim session. Each language pays
- this cost on first encounter, which may cause a brief stutter when a diff
- containing a new language first enters the viewport.
-
-- **Vim syntax fallback is deferred**: The vim syntax fallback (for languages
- without a treesitter parser) cannot run inside the decoration provider's
- redraw cycle due to Neovim's restriction on buffer mutations. Vim syntax
- highlights for cold hunks may appear one frame later. Warm hunks can reuse
- cached vim syntax spans, and stale deferred renders are ignored after buffer
- changes. The plugin reparses hunks after buffer edits and redraws visible
- highlights.
-
-- **Conflicting diff plugins**: `diffs.nvim` may not interact well with other
- plugins that modify diff highlighting. Known plugins that may conflict:
- - [`diffview.nvim`](https://github.com/sindrets/diffview.nvim) - provides its
- own diff highlighting and conflict resolution UI
- - [`mini.diff`](https://github.com/echasnovski/mini.diff) - visualizes buffer
- differences with its own highlighting system
- - [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim) - generally
- compatible, but both plugins modifying line highlights may produce
- unexpected results
- - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) -
- `diffs.nvim` now includes built-in conflict resolution; disable one or the
- other to avoid overlap
-
-# Acknowledgements
-
-- [`vim-fugitive`](https://github.com/tpope/vim-fugitive)
-- [`diffview.nvim`](https://github.com/sindrets/diffview.nvim)
-- [`difftastic`](https://github.com/Wilfred/difftastic)
-- [`mini.diff`](https://github.com/echasnovski/mini.diff)
-- [`gitsigns.nvim`](https://github.com/lewis6991/gitsigns.nvim)
-- [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim)
-- [@phanen](https://github.com/phanen) - diff header highlighting, unknown
- filetype fix, shebang/modeline detection, treesitter injection support,
- decoration provider highlighting architecture, gitsigns blame popup
- highlighting, intra-line bg visibility fix
-- [@tris203](https://github.com/tris203) - support for transparent backgrounds
-- [@letientai299](https://github.com/letientai299) - `diff.mnemonicPrefix`
- support
+- [vim-fugitive](https://github.com/tpope/vim-fugitive)
+- [diffview.nvim](https://github.com/sindrets/diffview.nvim)
+- [difftastic](https://github.com/Wilfred/difftastic)
+- [mini.diff](https://github.com/echasnovski/mini.diff)
+- [gitsigns.nvim](https://github.com/lewis6991/gitsigns.nvim)
+- [git-conflict.nvim](https://github.com/akinsho/git-conflict.nvim)
+- [@phanen](https://github.com/phanen) — header highlighting, filetype detection,
+ Treesitter injections, decoration providers, Gitsigns popups, and intra-line colors
+- [@tris203](https://github.com/tris203) — transparent backgrounds
+- [@letientai299](https://github.com/letientai299) — `diff.mnemonicPrefix` support
diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt
index bfa89261..f547b70f 100644
--- a/doc/diffs.nvim.txt
+++ b/doc/diffs.nvim.txt
@@ -1,1499 +1,221 @@
-*diffs.nvim.txt* Syntax highlighting for diffs in Neovim
-
-Author: Barrett Ruth License: ISC
+*diffs.nvim.txt* Language syntax highlighting for unified diffs
==============================================================================
-INTRODUCTION *diffs.nvim*
-
-diffs.nvim adds language-aware syntax highlighting to unified diff content in
-Neovim buffers. It replaces flat `diffAdded`/`diffRemoved` coloring with
-treesitter syntax, blended line backgrounds, and character-level intra-line
-diffs.
-
-With no configuration, diffs.nvim provides:
-- `gitcommit` diff highlighting (e.g., `git commit --verbose`)
-- Inline merge conflict detection, highlighting, and resolution
-- Background-only diff colors for `&diff` buffers (vimdiff, diffthis)
+CONTENTS *diffs.nvim*
-All other integrations are opt-in. See |diffs.nvim-integrations|.
+ Setup .................................... |diffs.nvim-setup|
+ Configuration ............................ |diffs.nvim-configuration|
+ Integrations ............................. |diffs.nvim-integrations|
+ Lua API .................................. |diffs.nvim-api|
+ Highlight groups ......................... |diffs.nvim-highlights|
+ Limitations .............................. |diffs.nvim-limitations|
+ Health ................................... |diffs.nvim-health|
-Features: ~
-- Treesitter syntax highlighting in diff hunks
-- Intra-line diff highlighting, character- or word-level per 'diffopt'
-- Vim syntax fallback for languages without a treesitter parser
-- Blended diff background colors that preserve syntax visibility
-- Optional diff prefix (`+`/`-`/` `) concealment
-- |:Diff| unified diff against any revision
-- |:Diff-review| full-repo review diff with qflist/loclist navigation
-- Email quoting/patch syntax support (`> diff ...`)
-
-==============================================================================
-CONTENTS *diffs.nvim-contents*
+Diffs highlights the code inside unified diff hunks with Treesitter or Vim
+syntax. It adds line backgrounds and intra-line highlights. It adjusts native
+diff colors through 'winhighlight'.
- 1. Introduction ............................................... |diffs.nvim|
- 2. Requirements .................................. |diffs.nvim-requirements|
- 3. Setup ................................................ |diffs.nvim-setup|
- 4. Configuration ....................................... |diffs.nvim-config|
- 5. Commands .......................................... |diffs.nvim-commands|
- 6. Mappings .......................................... |diffs.nvim-mappings|
- 7. Integrations .................................. |diffs.nvim-integrations|
- Fugitive ........................................ |diffs.nvim-fugitive|
- Neogit ............................................ |diffs.nvim-neogit|
- Neojj .............................................. |diffs.nvim-neojj|
- Gitsigns ........................................ |diffs.nvim-gitsigns|
- Committia ...................................... |diffs.nvim-committia|
- Telescope ...................................... |diffs.nvim-telescope|
- Difftastic .................................... |diffs.nvim-difftastic|
- 8. Conflict Resolution ............................... |diffs.nvim-conflict|
- 9. Merge Diff Resolution ................................ |diffs.nvim-merge|
- 10. API .................................................... |diffs.nvim-api|
- 11. Known Limitations .............................. |diffs.nvim-limitations|
- 12. Highlight Groups ................................ |diffs.nvim-highlights|
- 13. Health Check ........................................ |diffs.nvim-health|
- 14. Acknowledgements .......................... |diffs.nvim-acknowledgements|
+Diff commands, repository review, file comparison, and conflict resolution are
+available in diffs-collection.nvim:
+ https://github.com/barrettruth/diffs-collection.nvim
==============================================================================
-REQUIREMENTS *diffs.nvim-requirements*
+SETUP *diffs.nvim-setup*
-- Neovim 0.12.0+
-- Treesitter parsers for languages you want highlighted
-- Optional: the Treesitter `diff` parser for the best experience
+Requirements: Neovim 0.12.0 or later. Treesitter parsers are optional.
-==============================================================================
-SETUP *diffs.nvim-setup*
+Install with |vim.pack|: >lua
+ vim.pack.add({ 'https://github.com/barrettruth/diffs.nvim' })
+<
+Set |g:diffs| before the plugin loads. No setup function is required.
+Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys`.
+Load your colorscheme before diffs.nvim.
-With vim.pack (Neovim 0.12+): >lua
- vim.pack.add({
- 'https://github.com/barrettruth/diffs.nvim',
+Diffs attaches to `git` and `gitcommit` buffers by default. To attach to patch
+files with the `diff` filetype: >lua
+ vim.api.nvim_create_autocmd('FileType', {
+ pattern = 'diff',
+ callback = function(event)
+ require('diffs').attach(event.buf)
+ end,
})
<
-
-Do not lazy load with `event`, `lazy`, `ft`, `config`, or `keys` — diffs.nvim
-lazy-loads itself.
-
-NOTE: Load your colorscheme before diffs.nvim. With lazy.nvim, set `priority =
-1000` and `lazy = false` on your colorscheme plugin.
-
-See |diffs.nvim-config| for customization, |diffs.nvim-integrations| for
-plugin support.
-
==============================================================================
-CONFIGURATION *diffs.nvim-config*
+CONFIGURATION *diffs.nvim-configuration* *g:diffs*
-Configuration is done via `vim.g.diffs`. Set options before the plugin loads:
->lua
+Defaults: >lua
vim.g.diffs = {
debug = false,
- view = {
- default_layout = 'unified',
- prefix = true,
- },
- integrations = {},
+ view = { prefix = true },
highlights = {
background = true,
- blend_alpha = 0.6,
warn_max_lines = true,
- vim = true,
+ treesitter = { max_lines = 500 },
+ vim = { max_lines = 200 },
+ intra = { max_lines = 500 },
},
- conflict = true,
+ integrations = {},
}
<
+`debug`
+ Set to `true` for debug messages. Set to a file path to write a log.
- *diffs.nvim.Config*
- Fields: ~
- {debug} (boolean, default: false)
- Enable debug logging to `:messages` with
- `[diffs]` prefix.
-
- {view} (table, default: see below)
- Diff view display options.
- See |diffs.nvim.ViewConfig| for fields.
+`view.prefix`
+ Show unified diff prefixes. Set to `false` to conceal the prefixes.
- *diffs.nvim.ViewConfig*
- View table fields: ~
- {default_layout} (string, default: 'unified')
- Default layout for current-file |:Diff| when
- `++layout` is omitted. Accepted values are
- `unified`, `stacked`, and `split`. An explicit
- `++layout` option takes precedence. This does not
- affect |:Diff-review|, |diffs.nvim-diff-files|,
- Fugitive status mappings, or native diff mode.
+`highlights.background`
+ Apply background colors to added and removed lines.
- {prefix} (boolean, default: true)
- Show diff prefixes (`+`/`-`/` `). Set to
- `false` to hide them with virtual text overlay.
- When `highlights.background` is also enabled,
- the overlay inherits the line's background color.
+`highlights.blend_alpha`
+ Blend line backgrounds with the Normal background. The default is 0.6.
+ Values must be between 0 and 1. Higher values retain more of the diff color.
- {integrations} (table, default: {})
- List of enabled integration names, such as
- `{ 'fugitive', 'neogit' }`.
- See |diffs.nvim-integrations|.
- *diffs.nvim.IntegrationsConfig*
- Names: ~
+`highlights.warn_max_lines`
+ Show a warning when a hunk exceeds a configured line limit.
- {fugitive}
- Enable vim-fugitive support. See
- |diffs.nvim-fugitive|.
+`highlights.treesitter.max_lines`
+ Maximum hunk size for Treesitter syntax highlighting.
- {neogit}
- Enable Neogit support. See |diffs.nvim-neogit|.
+`highlights.vim`
+ Configure Vim syntax fallback with `{ max_lines = N }`.
+ Set to `false` to disable fallback. Set to `true` to use its default limit.
- {neojj}
- Enable neojj support. See |diffs.nvim-neojj|.
+`highlights.intra.max_lines`
+ Maximum hunk size for intra-line highlights.
- {gitsigns}
- Enable gitsigns.nvim popup support. See
- |diffs.nvim-gitsigns|.
-
- {committia}
- Enable committia.vim diff-pane support. See
- |diffs.nvim-committia|.
-
- {telescope}
- Enable telescope.nvim preview support. See
- |diffs.nvim-telescope|.
-
- {difftastic}
- Enable structural diffing via the `difft` binary.
- Use `{ args = {...} }` to forward flags. See
- |diffs.nvim-difftastic|.
-
- {highlights} (table, default: see below)
- Controls which highlight features are enabled.
- See |diffs.nvim.Highlights| for fields.
-
- {conflict} (boolean, default: true)
- Highlight and resolve inline merge conflicts.
-
- *diffs.nvim.Highlights*
- Highlights table fields: ~
- {background} (boolean, default: true)
- Apply background highlighting to `+`/`-` lines
- using `DiffsAdd`/`DiffsDelete` groups (derived
- from `DiffAdd`/`DiffDelete` backgrounds).
-
- {blend_alpha} (number, default: 0.6)
- Alpha value for diff line background intensity.
- Controls how strongly diff lines (adds/deletes)
- stand out from the editor background. Must be
- between 0 and 1 (inclusive).
-
- {warn_max_lines} (boolean, default: true)
- Show a `vim.notify()` warning when syntax
- highlighting is skipped because a hunk exceeds
- {max_lines}. See |diffs.nvim-max-lines|.
-
- {treesitter} (table, default: see below)
- Treesitter highlighting is always enabled when
- a parser is available.
- See |diffs.nvim.TreesitterConfig| for fields.
-
- {vim} (boolean|table, default: true)
- Vim syntax fallback highlighting options.
- See |diffs.nvim.VimConfig| for fields.
-
- {intra} (table, default: see below)
- Intra-line diff highlighting options.
- See |diffs.nvim.IntraConfig| for fields.
-
- *diffs.nvim.TreesitterConfig*
- Treesitter config fields: ~
- {max_lines} (integer, default: 500)
- Skip treesitter highlighting for hunks with more
- highlighted lines (`+`/`-`) than this threshold.
- Context lines are not counted. Prevents lag on
- massive diffs.
-
- *diffs.nvim.VimConfig*
- Vim config fields: ~
- {max_lines} (integer, default: 200)
- Skip vim syntax highlighting for hunks with more
- highlighted lines (`+`/`-`) than this threshold.
- Context lines are not counted.
-
- *diffs.nvim.IntraConfig*
- Intra config fields: ~
- {max_lines} (integer, default: 500)
- Skip character-level highlighting for hunks with
- more highlighted lines (`+`/`-`) than this
- threshold. Context lines are not counted.
-
- Note: Header context (e.g., `@@ -10,3 +10,4 @@ func()`) is always
- highlighted with treesitter when a parser is available.
-
- Language detection uses Neovim's built-in `vim.filetype.match()` and
- `vim.treesitter.language.get_lang()`. To customize filetype detection
- or register treesitter parsers for custom filetypes, use
- `vim.filetype.add()` and `vim.treesitter.language.register()`.
-
-==============================================================================
-MAX LINES *diffs.nvim-max-lines*
+All line limits must be at least 1. Large hunks retain line backgrounds.
-When a hunk contains more highlighted lines (`+`/`-`) than the configured
-threshold, diffs.nvim skips syntax highlighting for that hunk to avoid lag.
-Context lines (lines with a space prefix) are not counted toward the limit.
-
-A warning is shown when this happens: >
- [diffs]: Syntax highlighting skipped for 1 hunk(s) — too large.
+'diffopt' controls intra-line highlighting: >vim
+ set diffopt+=inline:word
<
-To increase the threshold: >lua
- vim.g.diffs = {
- highlights = {
- treesitter = { max_lines = 1000 }, -- default: 500
- vim = { max_lines = 500 }, -- default: 200
- },
- }
-< To suppress the warning without changing the threshold: >lua
- vim.g.diffs = {
- highlights = { warn_max_lines = false },
- }
-< The `intra.max_lines` threshold (default: 500) is separate and controls
-character-level diff highlighting within changed lines. It does not affect the
-syntax highlighting warning.
+Supported modes are `none`, `simple`, `char`, and `word`. Without an `inline:`
+entry, diffs uses `simple`. Whitespace options in 'diffopt' also apply.
==============================================================================
-COMMANDS *diffs.nvim-commands*
-
-diffs.nvim-owned views: ~
-
-|:Diff|, |:Diff-review|, split layouts, and Fugitive status maps create
-diffs.nvim-owned `diffs://` views. Ordinary `git`, `gitcommit`, `diff`,
-custom filetypes, picker previews, and third-party integration buffers remain
-highlight-only.
-
-:Diff [++layout=unified|stacked|split] [object] *:Diff*
- Open a diff of the current file against a diff object. The object language
- is defined at |diffs.nvim-diff-objects|. Use |:Diff-review| (`:Diff review`)
- for repository reviews.
- Without `++layout`, the layout comes from `view.default_layout`; an explicit
- option takes precedence. The default remains `unified`.
-
- If `++layout` occurs more than once, the last value applies. Unknown layout
- names use the unified layout.
-
- `++layout=unified` and `++layout=stacked` create generated `diffs://`
- buffers. Unified buffers include old and new line-number rails before the
- unified diff text. Stacked buffers use the same generated diff text with a
- single context-aware line-number rail; see |diffs.nvim-stacked-layout|.
- Generated layouts do not enter native Vim `&diff` mode or open writable
- Fugitive object buffers.
-
- `++layout=split` opens plugin-aligned paired old/new endpoint windows.
- See |diffs.nvim-paired-windows|.
-
- Prefix with |:vertical| to open the generated `unified` and `stacked`
- layouts in a vertical split (`:vertical Diff`). The modifier is ignored for
- `++layout=split`, which manages its own two-window arrangement;
- `:vertical Diff ++layout=split` warns and continues.
-
- Plain direct |:Diff| from a worktree file compares the index to the
- worktree and shows only unstaged changes. Staged-only changes report no
- changes for that edge. To view staged changes, use a staged Fugitive status
- row with |diffs.nvim-du|/|diffs.nvim-dU| or an explicit supported object
- form such as `:Diff :0:%`. |:Diff| does not provide a `--staged` flag.
-
- A single-file |:Diff| populates the window's |location-list| with that
- file's hunks and leaves the global |quickfix| list untouched. The quickfix
- list is taken over as a repo-wide file index only for multi-file views and
- the |:Diff-review| maps, not the review split layout, which navigates files
- itself and leaves the quickfix list untouched.
-
- Parameters: ~
- ++layout=unified (optional) Open a generated `diffs://` view with old
- and new line-number rails.
- ++layout=stacked (optional) Open a generated `diffs://` view with one
- context-aware line-number rail.
- ++layout=split (optional) Open the current single-file comparison as
- paired old/new endpoint windows.
- {object} (string, optional) The object to diff against; see
- |diffs.nvim-diff-objects|. Defaults to the current file in
- the index.
-
- Completion covers `++layout=...`, the `review` and `files` subcommands,
- supported object forms, and git refs.
-
- Examples: >vim
- :Diff " diff against the index
- :Diff main " diff against main branch
- :Diff HEAD~3 " diff against 3 commits ago
- :Diff :0:% " diff against the index explicitly
- :Diff abc123 " diff against specific commit
- :Diff ++layout=stacked
- :vertical Diff " open the generated diff in a vertical split
- :Diff files old.lua new.lua " diff two arbitrary files
-<
- Edge cases: ~
- - Untracked and added files render as all-added files.
- - Deleted files render as all-removed files.
- - Files with both staged and unstaged changes require one view per edge:
- plain |:Diff| shows unstaged changes; staged status rows show staged
- changes.
- - Unmerged files open a merge diff view. Native 3-way `:Gdiffsplit!`
- windows are not emulated.
- - Mode-only changes, binary files, submodules, and pure renames report a
- warning or no text changes.
-
- Hunk actions run `git apply --cached --check` before mutating the index.
- Stale hunks, context drift, and index mismatches fail without partial
- mutation. `dp` appears only for supported index -> worktree hunks/ranges;
- `do` appears only for supported tree -> index hunks/ranges.
-
-Diff objects: ~ *diffs.nvim-diff-objects*
-
- `:Diff [object]` takes a single object that names one side of the diff; the
- other side is that path's worktree (except the staged-context default). `%`
- is the current file.
-
- Supported objects: ~
- (omitted) Current file: index vs worktree (HEAD vs index in a staged
- context).
- {rev} Current file at {rev} vs worktree. {rev} is any git
- revision: `HEAD`, `@` (alias for HEAD), `HEAD~3`, a branch,
- a tag, or a commit hash.
- : Current file: index vs worktree. Also `:%` and `:0:%`.
- {rev}:% Current file at {rev} vs worktree. Also `@:%`, `HEAD:%`.
- {rev}:{path} The {path} at {rev} vs the {path} worktree file, for
- example `:Diff HEAD:lua/foo.lua`.
- :{path} The {path} in the index vs its worktree file. Also
- `:0:{path}`.
- :1:% :2:% :3:%
- The file at merge stage 1 (base), 2 (ours), or 3 (theirs)
- vs the worktree. Read-only, and valid only while the file is
- in a merge conflict. Also `:1:{path}` etc.
-
- Unsupported objects: ~
- These recognizable Fugitive forms are rejected with a specific error:
- - `#`, `#N` alternate-buffer expansion
- - `!`, `!:{path}` owner-commit expansion
- - `` cursor-file expansion
- - `./{path}`, `:(top){path}` worktree-relative and pathspec-magic paths
- - `:/{message}` commit-message search
- - `{a}..{b}`, `{a}...{b}` ranges; use |:Diff-review| instead
- - `{rev}:` tree objects
- - `-` the previous object
-
-Diff files: ~ *diffs.nvim-diff-files*
-
- `:Diff files [{left}] [{right}]` opens a read-only generated diff of two
- arbitrary files. Neither file has to be tracked by git, and the command
- works outside a repository. It is the file-path counterpart to a plain
- |:Diff| object: the first path names the old (left) side, and the second
- names the new (right) side.
-
- A named path is always read from disk. An omitted side falls back to the
- current buffer: the new side is the buffer's live content, and the old
- side is that buffer's file on disk. Bare `:Diff files` therefore compares
- the buffer to its last save, like `:Diff {rev}`, which also diffs against
- unsaved buffer content. A buffer-backed side is shown as `buffer:{name}`,
- and falls back to disk if that buffer is gone when the view reloads.
-
- Examples: >vim
- :Diff files " last save (old) vs buffer (new)
- :Diff files old.lua new.lua " old.lua (old) vs new.lua (new)
- :Diff files other.lua " other.lua (old) vs buffer (new)
- :Diff files ++layout=stacked a.txt b.txt
-<
- The `++layout=unified` and `++layout=stacked` layouts and the |:vertical|
- modifier apply as they do for |:Diff|. Other layout values use unified.
- Use native diff mode (`nvim -d` or |:diffsplit|) for a side-by-side comparison.
- The view is read-only, so the hunk-staging maps
- (`do`/`dp`) are not available.
-
- Errors: ~
- - More than two paths reports the expected count.
- - An omitted side needs a named current buffer.
- - A path that is a directory, missing, unreadable, or binary is refused and
- named; directories are intentionally unsupported. A buffer-backed side
- does not have to exist on disk.
- - Two identical sides report no changes without opening a buffer.
-
-Stacked generated layout: ~ *diffs.nvim-stacked-layout*
-
- `++layout=stacked` creates a generated single-column `diffs://` layout. It
- keeps normal unified diff text, but replaces the two old/new line-number
- rails used by the unified generated layout with one context-aware rail.
-
- In the stacked rail:
- - `-` lines display old-side line numbers.
- - `+` lines display new-side line numbers.
- - Context lines display new-side line numbers.
- - Headers and metadata do not display file line numbers.
-
- Replacements can therefore repeat a number: a deleted old-side line and the
- added new-side line that replaces it may both show the same value.
->diff
- | @@ -9,3 +9,3 @@
- 9 | alpha
- 10 | -beta
- 10 | +beta changed
- 11 | gamma
-<
-
-Paired-window behavior: ~ *diffs.nvim-paired-windows*
-
- `++layout=split` opens a single-file comparison as paired old/new endpoint
- windows: the old/removed endpoint on the left, the new/added endpoint on the
- right. Endpoint buffers are read-only `diffs://` views holding the real file
- content of each side; edits happen in real source buffers opened from the
- view.
-
- The panes are not native `&diff` windows. They are plugin-rendered: the
- plugin aligns both sides row-for-row by inserting blank filler rows, then
- keeps the windows in lockstep with `scrollbind`/`cursorbind`. Because the
- panes hold real, contiguous file content, treesitter highlights them as the
- target language. Each changed line is painted with the same palette as
- generated buffers, so its background is continuous across the gutter and the
- text, and changed regions within a line carry the same intra-line word-diff
- highlighting, honoring `highlights.intra`.
-
- Each pane renders its own side's real line numbers in a `statuscolumn` rail
- (blank on filler rows), so the rail does not depend on or conflict with a
- global `number`/`statuscolumn` setting; the originals are restored when the
- pair closes. Diff folds are disabled.
-
- `q` closes the pair. `:edit` refreshes the same comparison. `]c`/`[c`
- navigate hunks from either side. `` opens a real worktree file only when
- the current row maps to worktree content.
-
-
-:Diff review [++layout=...] [++nountracked] [review-spec] *:Diff-review*
- Open a repository review. `review` must be the first argument; a revision
- literally named `review` must be addressed another way (for example
- `refs/heads/review`).
-
- With no arguments, reviews the current repository state against the
- remote default branch (detected via `git -C symbolic-ref
- refs/remotes/origin/HEAD`). If the remote HEAD ref is not set, run
- `git remote set-head origin -a` first.
-
- With a single ref, reviews the current repository state against that
- base ref. With `A...B`, reviews explicit target `B` against base `A`
- using merge-base semantics. With `A..B`, reviews explicit target `B`
- against base `A` as a direct two-endpoint comparison. Two separate refs,
- such as `:Diff review main feature`, also compare the two endpoints.
- Additional revision arguments pass to Git.
-
- Git resolves the revisions and reports invalid revisions. In `A..B` and
- `A...B`, an omitted endpoint defaults to `HEAD`. If `A...B` has multiple
- merge bases, Git selects the base for the comparison.
- Tree expressions such as `HEAD~:src..HEAD:src` compare paths relative to
- the selected trees.
-
- A current-state review (no explicit target) ends with an Untracked
- section listing files not yet tracked by git. Pass `++nountracked` to
- omit it. To omit it every time, wrap the command, for example: >vim
- command! Review Diff review ++nountracked
-<
- Explicit-target reviews (`A...B`, `A..B`) never include untracked files,
- so the option has no effect there.
-
- `++layout=unified` and `++layout=stacked` both create full generated review
- maps. Unified review maps use old and new line-number rails; stacked review
- maps use one context-aware line-number rail as described at
- |diffs.nvim-stacked-layout|. These layouts populate the quickfix list with
- file entries (one per changed file, with `+N`/`-M` stats) and the location
- list with the hunk entries of the file under the cursor (one per `@@`
- header), refreshed as you move between files. All entries point into the
- diff buffer, so `:cnext`/`:cprev` navigate between files and `:lnext`/
- `:lprev` navigate between hunks of the current file.
-
- Reloading with `:edit` refreshes the same review. If a `diffs://` window
- already exists in the current tabpage, the new diff replaces its buffer
- instead of creating another split.
-
- Reloads require the source metadata stored when the plugin creates the
- view. If that metadata is unavailable, reopen the view with |:Diff-review|.
-
- With `++layout=split`, |:Diff-review| opens one review file as a paired
- side-by-side diff instead of the full review map: the old/removed endpoint
- on the left, the new/added endpoint on the right. Both panes are read-only
- plugin-rendered `diffs://` endpoint views, row-aligned with filler rows and
- bound for synchronized scrolling and cursor movement, with the same line and
- intra-line word-diff highlighting as generated buffers
- (see |diffs.nvim-paired-windows|).
-
- In generated review maps and split panes, `gs` toggles between the full
- review map and the side-by-side split for the selected file. From a generated
- map it uses the file under the cursor (or current quickfix item); from a
- split it closes the pair and restores the generated layout it came from
- (`unified` or `stacked`, defaulting to `unified` for direct split opens).
- This is a buffer-local default backed by
- |(diffs-review-toggle-layout)|, set only when you have not already
- mapped `gs` in that buffer.
-
- The toggle carries the cursor line in both directions. The split opens on
- the source line the map cursor was on, and returning lands on the map line
- for the source line the split cursor is on, so moving inside the split is
- reflected in the map. Map lines with no source line of their own, such as
- file and hunk headers, open the split at the first change of the hunk.
-
- The split shows a removed line and the line that replaced it on one row,
- which the map shows as two. Returning uses the line under the cursor, so
- leaving from the right pane lands on the `+` line of such a pair even when
- the `-` line is what selected it. Toggling again keeps that line.
-
- In split layout, `]q`/`[q` (also `]f`/`[f`) step to the next/previous changed
- file in the review (wrapping at the ends), swapping the displayed pair in
- place and reusing the two windows. Each step (and the initial open) echoes
- the file's position in the review, for example `[diffs]: (2 of 5):
- [Unstaged] lua/rate.lua`. These are buffer-local defaults backed by
- |(diffs-review-next-file)| and |(diffs-review-prev-file)|, set
- only when you have not already mapped those keys in the buffer. Counts
- repeat the step. `gO` opens |vim.ui.select()| to jump to any file in the review
- (see |(diffs-review-select-file)|). These mappings use the review's
- file index; the location list holds the active file's hunks
- (`:lnext`/`:lprev`). A quickfix from another surface
- (such as an open review map) is left untouched, so `:cnext`/`:cprev` follow
- that list. If the review map is hidden, on its quickfix file entries
- selects the file in the matching visible split review. `]c`/`[c` navigate
- hunks within the active file across both panes, and `q` closes the pair.
-
- Prefix with |:vertical| to open the generated `unified` and `stacked`
- review maps in a vertical split (`:vertical Diff review`). As with |:Diff|,
- the modifier is ignored for `++layout=split`;
- `:vertical Diff review ++layout=split` warns and continues.
-
- Parameters: ~
- ++layout=unified (optional) Open the default generated review map with
- old and new line-number rails.
- ++layout=stacked (optional) Open a generated review map with one
- context-aware line-number rail.
- ++layout=split (optional) Open the first renderable changed review file
- as a paired side-by-side endpoint diff.
- ++nountracked (optional) Omit the Untracked section of a
- current-state review (shown by default).
- {review-spec} (string, optional) One of:
- - empty: review current repo state against the repo
- default branch
- - `{base}`: review current repo state against `{base}`
- - `{base}...{target}`: merge-base review of explicit
- target `{target}` against base `{base}`
- - `{base}..{target}` or `{base} {target}`: direct review
- of explicit target `{target}` against base `{base}`
-
- If `++layout` occurs more than once, the last value applies. Unknown layout
- names use the unified layout. Repeating `++nountracked` has no additional
- effect.
-
- Completion covers `++layout=...`, `++nountracked`, and git refs.
-
- Examples: >vim
- :Diff review
- :Diff review ++layout=stacked origin/main
- :Diff review ++layout=split
- :Diff review ++nountracked
- :Diff review origin/main
- :vertical Diff review origin/main
- :Diff review origin/main...refs/forge/pr/42
- :Diff review origin/main..feature/topic
-<
-
- Lua command helper: >lua
- require('diffs.commands').review({
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- repo = '/path/to/repo',
- })
-
- require('diffs.commands').review_split()
-<
- Fields: ~
- {base} (string, optional) Base ref for the review. Defaults to the
- repository's remote default branch.
- {target} (string, optional) Explicit target ref to review. Omit to
- review the current repository state.
- {mode} (string, optional) `'merge-base'` or `'direct'`. Defaults to
- `'merge-base'` when `{target}` is set.
- {repo} (string, optional) Path inside the repository to review.
- Use this from async/plugin contexts when the current buffer
- is not a reliable repo hint.
- {vertical} (boolean, optional) Open the review in a vertical split.
- {untracked} (boolean, optional) Include the Untracked section of a
- current-state review. Defaults to true.
-
- Utilities: ~
- `require('diffs.commands').review_split()` opens the currently
- selected |:Diff-review| file in the same paired side-by-side diff used
- by `:Diff review ++layout=split`. It uses the review buffer cursor,
- quickfix item, or loclist item as the file selection. Once the split is
- open, `]q`/`[q` (also `]f`/`[f`) step between the review's files.
-
- These helpers are command integration helpers for |:Diff-review|. The stable
- root Lua API remains the small surface documented at |diffs.nvim-api|.
-
-Whitespace handling: ~ *diffs.nvim-whitespace*
-
- Generated diffs honor Neovim's |'diffopt'| whitespace flags, in both the
- line-level diff and the intra-line character highlights. There is no
- separate plugin option; 'diffopt' is the single lever.
-
- iwhite Ignore changes in the amount of whitespace (git `-b`).
- iwhiteall Ignore all whitespace (git `-w`).
- iwhiteeol Ignore whitespace changes at end of line.
- iblank Ignore changes whose lines are blank.
-
- The default is to show every whitespace difference. Set a baseline with
- 'diffopt', for example: >vim
- set diffopt+=iwhiteall
-<
- The same flags apply to |:Diff|, |:Diff-review|, and every layout, because
- the generated diffs read 'diffopt' as they are rendered.
-
- Changes to 'diffopt' take effect live: whenever the option changes the
- generated `diffs://` buffers regenerate, the decorated surfaces repaint, and
- native |:diffsplit| windows are refreshed with |:diffupdate|. There is no
- dedicated mapping; toggle whitespace the standard Vim way by adding or
- removing a flag from 'diffopt' (for example with a small `:set` mapping of
- your own).
-
- What an ignored whitespace change looks like depends on the surface,
- because the plugin removes the noise where it generates the diff and only
- re-styles it where the text cannot be regenerated:
-
- - Generated `diffs://` buffers (|:Diff|, the staged/unstaged sections, and
- |:Diff-review|): regenerated with the matching git/|vim.diff()| flag, so
- a whitespace-only hunk is dropped entirely and never appears.
- - Split layout (`++layout=split`): regenerated the same way, so a
- whitespace-only change shows as plain, uncolored context — both sides
- stay visible with their own indentation, just without add/delete color.
- - Decorated, non-generated buffers (`gitcommit` from `git commit -v`,
- vim-fugitive, Neogit, plain `diff` buffers): the text cannot be
- regenerated, so a `+`/`-` line whose only change is whitespace is kept
- but de-emphasized — the prefix and syntax are kept and the intra-line
- emphasis dropped, while the added/removed background becomes a faint,
- neutral |DiffsDim|. Lines that mix a real change with whitespace stay
- fully highlighted.
- - difftastic-rendered diffs: left unchanged; the 'diffopt' whitespace flags
- do not apply (see |diffs.nvim-difftastic|).
- - Native |:diffsplit| windows: whitespace is handled by Neovim itself.
-
- Limitations: ~
- - 'diffopt' `algorithm` and `linematch` are honored by the generated diffs
- too. `linematch` has no git equivalent, so it shapes the file diffs
- computed with |vim.diff()| but not the git-backed surfaces (|:Diff-review|
- and the staged/unstaged section diffs).
-
-Inline granularity: ~ *diffs.nvim-inline*
-
- How much of a changed line is emphasized comes from 'diffopt' `inline:`, so
- diffs.nvim surfaces match native |:diffsplit| windows. As with whitespace,
- there is no separate plugin option.
-
- inline:none No intra-line emphasis. Changed lines keep only their
- line-level background.
- inline:simple One span per line, from the first differing byte to
- the last.
- inline:char A span per differing run of characters.
- inline:word Spans grown to whole words, with gaps of five bytes or
- less of non-word characters merged into one block.
-
- Neovim treats a 'diffopt' with no `inline:` value as `inline:simple`, and
- diffs.nvim resolves it the same way. To emphasize whole words: >vim
- set diffopt+=inline:word
-<
- The setting takes effect live through the same 'diffopt' repaint described
- above, so a plain `:set`, an unimpaired-style toggle, or an autocmd all
- work. There is no dedicated mapping.
-
- *diffs.nvim-diffopt*
- Intra-line highlighting is controlled by |'diffopt'|.
-
- Disable intra-line highlighting: >lua
- vim.opt.diffopt:remove({ 'inline:simple', 'inline:char', 'inline:word' })
- vim.opt.diffopt:append('inline:none')
-<
- To enable word-level highlighting when opening a diff buffer: >lua
- vim.api.nvim_create_autocmd('FileType', {
- pattern = { 'diff', 'git', 'gitcommit' },
- callback = function()
- vim.opt.diffopt:remove({ 'inline:none', 'inline:simple', 'inline:char' })
- vim.opt.diffopt:append('inline:word')
- end,
- })
-<
- 'diffopt' is global: this changes all diff views. For attachment to `diff`
- buffers, see |diffs.nvim-extra-filetypes|.
-
-==============================================================================
-MAPPINGS *diffs.nvim-mappings*
-
- *(diffs-diff)*
-(diffs-diff) Show index-to-worktree unified diff in a horizontal
- split. Equivalent to |:Diff| with no arguments.
-
- *(diffs-diff-vertical)*
-(diffs-diff-vertical) Show index-to-worktree unified diff in a
-vertical
- split. Equivalent to `:vertical Diff` with no
- arguments.
-
- *(diffs-diff-open-source)*
-(diffs-diff-open-source)
- Open the real worktree file at the current |:Diff|
- hunk location. `diffs://` buffers map this to
- by default.
-
- *(diffs-review-open-split)*
-(diffs-review-open-split)
- Open the currently selected |:Diff-review| file as a
- paired side-by-side diff: the old/removed endpoint on
- the left, the new/added endpoint on the right.
-
- *(diffs-review-toggle-layout)*
-(diffs-review-toggle-layout)
- Toggle the current review buffer between the generated
- review map and split layout. Mapped to `gs` by default
- in generated review maps and review split panes, unless
- `gs` is already mapped in that buffer. See
- |diffs.nvim.review_toggle_layout()|.
-
- *(diffs-review-next-file)*
-(diffs-review-next-file)
- In a |:Diff-review| split, swap the displayed pair to
- the next changed file, wrapping at the end. Mapped to
- `]q` and `]f` by default in the split panes, unless
- already mapped in that buffer. Accepts a count.
-
- *(diffs-review-prev-file)*
-(diffs-review-prev-file)
- As |(diffs-review-next-file)|, but the previous
- changed file. Mapped to `[q` and `[f` by default.
-
- *(diffs-review-select-file)*
-(diffs-review-select-file)
- In a |:Diff-review| split, prompt with |vim.ui.select()|
- for one of the review's files and jump to it. Mapped to
- `gO` by default in the split panes — a review's files are
- its table of contents — unless `gO` is already mapped in
- that buffer. See |diffs.nvim.select_review_file()|.
-
- *(diffs-refresh)*
-(diffs-refresh)
- Re-render the current `diffs://` buffer in place,
- preserving the cursor and view. Useful after changing
- 'diffopt' directly. Comparable to |:diffupdate|.
- Generated diff buffers map to this action,
- clear search highlighting, update native diffs, and
- redraw. Existing buffer-local mappings are kept.
-
-Example configuration: >lua
- vim.keymap.set('n', 'gd', '(diffs-diff)')
- vim.keymap.set('n', 'gD', '(diffs-diff-vertical)')
-<
-
- *(diffs-conflict-ours)*
-(diffs-conflict-ours)
- Accept current (ours) change. Replaces the
- conflict block with ours content.
-
- *(diffs-conflict-theirs)*
-(diffs-conflict-theirs)
- Accept incoming (theirs) change. Replaces the
- conflict block with theirs content.
-
- *(diffs-conflict-both)*
-(diffs-conflict-both)
- Accept both changes (ours then theirs).
-
- *(diffs-conflict-none)*
-(diffs-conflict-none)
- Reject both changes (delete entire block).
-
- *(diffs-conflict-next)*
-(diffs-conflict-next)
- Jump to next conflict marker. Wraps around.
-
- *(diffs-conflict-prev)*
-(diffs-conflict-prev)
- Jump to previous conflict marker. Wraps around.
-
-Example configuration: >lua
- vim.keymap.set('n', 'co', '(diffs-conflict-ours)')
- vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)')
- vim.keymap.set('n', 'cb', '(diffs-conflict-both)')
- vim.keymap.set('n', 'c0', '(diffs-conflict-none)')
- vim.keymap.set('n', ']x', '(diffs-conflict-next)')
- vim.keymap.set('n', '[x', '(diffs-conflict-prev)')
-<
-
- *(diffs-merge-ours)*
-(diffs-merge-ours)
- Accept ours in a merge diff view. Resolves the
- conflict in the working file with ours content.
-
- *(diffs-merge-theirs)*
-(diffs-merge-theirs)
- Accept theirs in a merge diff view.
+INTEGRATIONS *diffs.nvim-integrations*
- *(diffs-merge-both)*
-(diffs-merge-both)
- Accept both (ours then theirs) in a merge diff view.
-
- *(diffs-merge-none)*
-(diffs-merge-none)
- Reject both in a merge diff view.
-
- *(diffs-merge-next)*
-(diffs-merge-next)
- Jump to next unresolved conflict hunk in merge diff.
-
- *(diffs-merge-prev)*
-(diffs-merge-prev)
- Jump to previous unresolved conflict hunk in merge
- diff.
-
-Diff buffer mappings: ~
- *diffs.nvim-q*
- q Close the diff window. Available in all `diffs://`
- buffers created by |:Diff| or the fugitive status
- keymaps. In paired split views, close the pair; see
- |diffs.nvim-paired-windows|.
- ]c Jump to the next hunk boundary in unified |:Diff|
- buffers.
- [c Jump to the previous hunk boundary in unified |:Diff|
- buffers.
- Open the real worktree file at the current |:Diff| hunk
- location when the current endpoint row is worktree-backed.
- dp In Normal mode, stage the current whole hunk when the
- buffer compares the index to the worktree. In Visual mode,
- stage selected changed lines inside one hunk.
- do In Normal mode, unstage the current whole hunk when the
- buffer compares a tree to the index. In Visual mode,
- unstage selected changed lines inside one hunk.
-
-==============================================================================
-INTEGRATIONS *diffs.nvim-integrations*
-
-diffs.nvim integrates with several plugins. There are two attachment patterns:
-
-Automatic: ~ List integrations in the config. The plugin registers `FileType`
-autocmds for each integration's filetypes and attaches automatically.
->lua
- vim.g.diffs = {
- integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns', 'committia' },
- }
-<
-
-Opt-in: ~ *diffs.nvim-extra-filetypes*
-
-For other filetypes, register a FileType autocmd before opening the buffers.
->lua
- vim.api.nvim_create_autocmd('FileType', {
- group = vim.api.nvim_create_augroup('MyDiffs', { clear = true }),
- pattern = { 'diff', 'myplugin-diff' },
- callback = function(event)
- require('diffs').attach(event.buf)
- end,
- })
-<
-
-------------------------------------------------------------------------------
-FUGITIVE *diffs.nvim-fugitive*
-
-Enable vim-fugitive (https://github.com/tpope/vim-fugitive) support: >lua
- vim.g.diffs = { integrations = { 'fugitive' } }
-<
-
-`:Git` status and commit views receive treesitter syntax, line backgrounds,
-and intra-line diffs. |:Diff| opens a unified diff against any revision (see
-|diffs.nvim-commands|).
-
-Fugitive status keymaps: ~
-
-When inside a `:Git` status buffer, diffs.nvim provides keymaps to open
-unified `diffs://` views for files or entire sections. These keymaps reuse
-Fugitive status context for endpoint selection; they do not call `:Gdiffsplit`
-or open native Vim diff windows.
-
-Keymaps: ~
- *diffs.nvim-du* *diffs.nvim-dU*
- du Open unified diff in a horizontal split.
- dU Open unified diff in a vertical split.
-
-These keymaps work on:
-- File lines (e.g., `M src/foo.lua`) - opens diff for that file
-- Section headers (e.g., `Staged (3)`) - opens diff for all files in section
-- Hunk/context lines below a file - opens diff for the parent file
-
-Behavior by file status: ~
-
- Status Section Base Current Result ~
- M Unstaged index working tree unstaged changes
- M Staged HEAD index staged changes
- A Staged (empty) index file as all-added
- D Staged HEAD (empty) file as all-removed
- R Staged HEAD:oldname index:newname content diff
- U Unstaged :2: (ours) :3: (theirs) merge diff
- ? Untracked (empty) working tree file as all-added
-
-On section headers, the keymap runs `git diff` (or `git diff --cached` for
-staged) and displays all changes in that section as a single unified diff
-without hunk actions. Empty staged or unstaged sections report which section
-has no changes. Untracked section headers show a warning since there is no
-meaningful diff.
-
-This status-row behavior is intentionally narrower than Fugitive
-`:Gdiffsplit`. It preserves the useful edge selection model while keeping
-`diffs://` buffers read-only, explicit, and owned by diffs.nvim.
-
-Configuration: ~
->lua
+Enable integrations by name: >lua
vim.g.diffs = {
- integrations = { 'fugitive' },
+ integrations = { 'fugitive', 'neogit', 'neojj', 'gitsigns',
+ 'committia', 'telescope' },
}
<
- Remove `'fugitive'` from the list to disable it. Status keymaps are fixed
- at `du` and `dU`.
-
-------------------------------------------------------------------------------
-NEOGIT *diffs.nvim-neogit*
-
-Enable Neogit (https://github.com/NeogitOrg/neogit) support: >lua
- vim.g.diffs = { integrations = { 'neogit' } }
-<
-
-Expanding a diff in a Neogit buffer (e.g., TAB on a file in the status view)
-applies treesitter syntax highlighting and intra-line diffs to the hunk lines.
-
-Configuration: ~
- Use `neogit = false` to disable.
-
-------------------------------------------------------------------------------
-NEOJJ *diffs.nvim-neojj*
-
-Enable neojj (https://github.com/NicholasZolton/neojj) support: >lua
- vim.g.diffs = { integrations = { 'neojj' } }
-<
-
-Expanding a diff in a neojj buffer (e.g., TAB on a file in the status view)
-applies treesitter syntax highlighting and intra-line diffs to the hunk lines.
-
-Configuration: ~
- Use `neojj = false` to disable.
-
-------------------------------------------------------------------------------
-GITSIGNS *diffs.nvim-gitsigns*
-
-Enable gitsigns.nvim (https://github.com/lewis6991/gitsigns.nvim) popup
-integration for blame hunk previews: >lua
- vim.g.diffs = { integrations = { 'gitsigns' } }
-<
-
-Configuration: ~
- Use `gitsigns = false` to disable.
-
-`:Gitsigns blame_line full=true` popups receive treesitter syntax, line
-backgrounds, and intra-line diffs.
-
-Highlights are applied in a separate `diffs-gitsigns` namespace and do not
-interfere with the main decoration provider used for diff buffers.
-
-------------------------------------------------------------------------------
-COMMITTIA *diffs.nvim-committia*
-
-Enable committia.vim (https://github.com/rhysd/committia.vim) diff-pane
-support: >lua
- vim.g.diffs = { integrations = { 'committia' } }
-<
-
-Use `committia = false` to disable.
-
-------------------------------------------------------------------------------
-TELESCOPE *diffs.nvim-telescope*
-
-Enable telescope.nvim (https://github.com/nvim-telescope/telescope.nvim)
-preview highlighting: >lua
- vim.g.diffs = { integrations = { 'telescope' } }
-<
-
-Telescope does not set `filetype=diff` on preview buffers — it calls
-`vim.treesitter.start(bufnr, "diff")` directly, so diffs.nvim's `FileType`
-autocmd never fires. This integration listens for the `User
-TelescopePreviewerLoaded` event and attaches to the preview buffer.
-
-Pickers that show diff content (e.g. `git_bcommits`, `git_status`) will
-receive treesitter syntax, line backgrounds, and intra-line diffs in the
-preview pane.
-
-Known issue: Telescope's previewer may render the first line of the preview
-buffer with a black background regardless of colorscheme. This is a Telescope
-artifact unrelated to diffs.nvim. Tracked upstream:
-https://github.com/nvim-telescope/telescope.nvim/issues/3626
-
-------------------------------------------------------------------------------
-DIFFTASTIC *diffs.nvim-difftastic*
-
-Enable structural diffing via difftastic
-(https://github.com/Wilfred/difftastic). This integration is different from
-the others: difftastic is not a plugin whose buffers are decorated, but an
-external `difft` binary used as a structural diff engine. >lua
- vim.g.diffs = { integrations = { 'difftastic' } }
- vim.g.diffs = { integrations = { difftastic = { args = { '--ignore-comments' } } } }
-<
-
-The `difft` binary must be installed and on `PATH`. When it is missing,
-disabled, errors, or times out, diffs.nvim silently falls back to its normal
-git/intra rendering. The table form forwards `args` to difft verbatim; do not
-pass output-format flags (`--display`, `--color`), which diffs.nvim controls.
-
-When enabled, difftastic augments the diffs you explicitly open — |:Diff|,
-`:Diff ++layout=split`, `:Diff files`, and staged/untracked file diffs — with:
-
- - structural intra-line token highlighting (the actual edited tokens, with
- reformatting/reindentation recognized as unchanged), and
- - structural side-by-side row alignment in the split layout.
-
-It deliberately does not touch the live/ambient surfaces (fugitive, neogit,
-the builtin `diff` filetype), the multi-file review map, conflicts, or binary
-files; those render unchanged. In the unified layouts difftastic only refines
-the token layer (the +/- line structure stays git's), so its line-level
-structural wins appear in the split layout.
-
-difftastic runs synchronously when you open the diff, with an internal
-timeout. It is independent of |diffs.nvim-config| `highlights.intra`, which
-configures the built-in engine and is the fallback when difftastic is off.
-When difftastic finds no structural change (e.g. a reformat-only edit), it
-emits an informational message and renders no token highlights. The 'diffopt'
-whitespace handling does not apply to a structural diff; such buffers are left
-unchanged.
-
-Run |:checkhealth| diffs to confirm `difft` is detected.
-
-==============================================================================
-CONFLICT RESOLUTION *diffs.nvim-conflict*
-
-diffs.nvim detects inline merge conflict markers (`<<<<<<<`/`=======`/
-`>>>>>>>`) in working files and provides highlighting and resolution actions.
-Both standard and diff3 (`|||||||`) formats are supported.
-
-Conflict regions are detected automatically on `BufReadPost` and re-scanned on
-`TextChanged`. When all conflicts in a buffer are resolved, highlighting is
-removed.
-
-Buffer-local mappings: ~ *diffs.nvim-conflict-keymaps*
-
-Set buffer-local mappings when conflicts are detected: >lua
- vim.api.nvim_create_autocmd('User', {
- pattern = 'DiffsConflictDetected',
- callback = function(event)
- local opts = { buffer = event.data.bufnr }
- vim.keymap.set('n', 'co', '(diffs-conflict-ours)', opts)
- vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)', opts)
- vim.keymap.set('n', 'cb', '(diffs-conflict-both)', opts)
- vim.keymap.set('n', 'c0', '(diffs-conflict-none)', opts)
- vim.keymap.set('n', ']x', '(diffs-conflict-next)', opts)
- vim.keymap.set('n', '[x', '(diffs-conflict-prev)', opts)
- end,
- })
-<
-
-Diagnostic suppression: ~ *diffs.nvim-conflict-diagnostics*
-
-Hide diagnostics while a buffer contains merge conflicts: >lua
- vim.api.nvim_create_autocmd('User', {
- pattern = {
- 'DiffsConflictDetected',
- 'DiffsConflictResolved',
- 'DiffsConflictDetached',
- },
- callback = function(event)
- local bufnr = event.data.bufnr
- if event.match == 'DiffsConflictDetected' then
- if vim.diagnostic.is_enabled({ bufnr = bufnr }) then
- vim.b[bufnr].diffs_restore_diagnostics = true
- vim.diagnostic.enable(false, { bufnr = bufnr })
- end
- elseif vim.b[bufnr].diffs_restore_diagnostics then
- vim.b[bufnr].diffs_restore_diagnostics = nil
- vim.diagnostic.enable(true, { bufnr = bufnr })
- end
- end,
- })
-<
-
-User events: ~
-
-All events below include their target buffer number in `event.data.bufnr`.
-Conflict events identify the working buffer. `DiffsMergeReady` identifies the
-generated merge buffer.
-
- *DiffsConflictDetected*
- DiffsConflictDetected Fired when conflicts first appear in an attached
- buffer, including when undo restores markers.
-
- *DiffsConflictResolved*
- DiffsConflictResolved Fired when the last conflict in a buffer is resolved.
-
- *DiffsConflictDetached*
- DiffsConflictDetached Fired when conflict highlighting is explicitly
- detached from a buffer.
-
- *DiffsMergeReady*
- DiffsMergeReady Fired after a generated merge view is initialized
- or refreshed.
-
-==============================================================================
-MERGE DIFF RESOLUTION *diffs.nvim-merge*
-
-When running plain |:Diff| or an explicit index-object comparison (for example
-`:Diff :%`) on an unmerged worktree file, or pressing `du`/`dU` on an unmerged
-(`U`) file in the fugitive status buffer, diffs.nvim opens a unified diff of
-ours (`git show :2:path`) vs theirs (`git show :3:path`) with full treesitter
-and intra-line highlighting. Revision comparisons such as `:Diff HEAD` keep
-rendering that revision against the conflicted worktree buffer.
-
-The |(diffs-merge-ours)| family resolves conflicts in the working file
-by matching diff hunks to conflict markers.
-
-Set buffer-local mappings for merge views: >lua
- vim.api.nvim_create_autocmd('User', {
- pattern = 'DiffsMergeReady',
- callback = function(event)
- local opts = { buffer = event.data.bufnr }
- vim.keymap.set('n', 'co', '(diffs-merge-ours)', opts)
- vim.keymap.set('n', 'ct', '(diffs-merge-theirs)', opts)
- vim.keymap.set('n', 'cb', '(diffs-merge-both)', opts)
- vim.keymap.set('n', 'c0', '(diffs-merge-none)', opts)
- vim.keymap.set('n', ']x', '(diffs-merge-next)', opts)
- vim.keymap.set('n', '[x', '(diffs-merge-prev)', opts)
- end,
- })
+A map is also accepted: >lua
+ vim.g.diffs = { integrations = { fugitive = true, neogit = true } }
<
+Fugitive
+ Attach to `fugitive` buffers.
-Hunks that correspond to auto-merged content (no conflict markers) show an
-informational notification and are left unchanged.
-
-The working file buffer is modified in place; save it when ready. Inline
-conflict highlights (see |diffs.nvim-conflict|) are refreshed automatically
-after each resolution.
+Neogit
+ Attach to `NeogitStatus`, `NeogitCommitView`, and `NeogitDiffView` buffers.
+ Diffs renders intra-line colors in place of Neogit's inline groups.
-Git mergetool: *diffs.nvim-git-mergetool*
+neojj
+ Attach to `NeojjStatus`, `NeojjCommitView`, and `NeojjDiffView` buffers.
-diffs.nvim can be used with `git mergetool` by configuring Git to open the
-merged working file in Neovim:
->gitconfig
- [merge]
- tool = diffs-nvim
+Gitsigns
+ Highlight unified diff previews and blame popups.
- [mergetool "diffs-nvim"]
- cmd = nvim "$MERGED"
-<
-
-When the file opens, diffs.nvim detects conflict markers automatically.
-For buffer-local resolution mappings, see |diffs.nvim-conflict-keymaps|.
-From a Git-unmerged file, |:Diff| can also open the
-generated ours-vs-theirs merge diff view documented above.
+committia
+ Highlight the diff buffer in the commit layout.
-Save and quit after resolving conflicts. Use `:cq` to abort.
+Telescope
+ Highlight diff buffers in Telescope previews.
==============================================================================
-API *diffs.nvim-api*
-
-attach({bufnr}) *diffs.nvim.attach()*
- Attach highlighting to a buffer. Called automatically when the buffer's
- filetype matches `git`, `gitcommit`, or an enabled integration filetype.
- Direct calls work with any filetype. For automatic attachment to other
- filetypes, see |diffs.nvim-extra-filetypes|.
-
- Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
-
-refresh({bufnr}) *diffs.nvim.refresh()*
- Manually refresh highlighting for a buffer. Useful after external changes
- or for debugging.
-
- Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
-
- *diffs.ReviewSpec*
-A `diffs.ReviewSpec` says what to review, as the arguments to |:Diff-review|
-do: ~
- {args} (string[], optional) Git revision arguments. Overrides {base},
- {target}, and {mode}.
- {base} (string, optional) The ref to review against.
- {target} (string, optional) The ref to review. Omit for the current
- repository state.
- {mode} ("merge-base"|"direct", optional) How {base} and {target}
- relate, matching `A...B` and `A..B` respectively.
- {repo} (string, optional) Repository root. Defaults to the one the
- current buffer is in.
- {untracked} (boolean, optional) Include untracked files in a
- current-state review. Ignored when {target} is given.
-
-open_review({spec}, {opts}) *diffs.nvim.open_review()*
- Open a review, as |:Diff-review| does, for a caller that already knows
- what it wants reviewed. Returns the review buffer, or `nil` on failure.
-
- {opts.replace_win} is why this exists alongside the command: |:Diff|
- has nowhere to say "this window", so with no review already open it
- splits. A plugin handing a review off from its own buffer already has a
- window and wants that one used, rather than a new one and the rest of the
- tab closed to compensate. The window is not closed or created, only its
- buffer replaced.
+LUA API *diffs.nvim-api*
- Parameters: ~
- {spec} (|diffs.ReviewSpec|, optional) What to review. Omit to
- review the current repository state against the remote
- default branch, as `:Diff review` with no arguments does.
- {opts} (table, optional) ~
- • {layout} ("unified"|"stacked"|"split", optional) As
- `++layout`. Defaults to `view.default_layout`.
- • {replace_win} (integer, optional) Show the review in this
- window instead of splitting.
+Use `require('diffs')` to access the public API.
- Example, reviewing a pull request in the window you are standing in: >lua
- require('diffs').open_review(
- { base = 'main', target = 'refs/forge/pull/12', mode = 'merge-base' },
- { replace_win = vim.api.nvim_get_current_win() }
- )
-< The rest of the review API is buffer-centric. Navigation functions resolve
-the review split that {bufnr} (default: current buffer) belongs to, and are
-no-ops (`nil`/`false`) elsewhere. |diffs.nvim.review_toggle_layout()| also
-accepts a generated review map buffer and can move between map and split
-surfaces. Those helpers are only meaningful inside an open review.
+attach({bufnr}, {opts}) *diffs.nvim-attach()*
+ Attach highlighting to a buffer. Omit {bufnr}, or use 0, for the current
+ buffer. Without {opts}, diffs parses unified diff text in the buffer.
- *diffs.ReviewFile*
-A `diffs.ReviewFile` describes one changed file in a review: ~
- {path} (string) Repo-relative path, e.g. `lua/rate.lua`.
- {key} (string) Opaque, stable identifier; pass it to
- |diffs.nvim.review_goto()|. Prefer it over {path} as a
- target, since a path can recur across sections. Do not
- parse it; read {section}/{path} from the fields instead.
- {section} (string|nil) Section id for logic, e.g. `unstaged`.
- {section_label} (string|nil) Display label, e.g. `Unstaged`.
- {added} (integer) Added line count.
- {removed} (integer) Removed line count.
- {skipped} (boolean) True when split review navigation skips this file.
+ Optional fields in {opts}:
+ `hunks` Function that receives the buffer number and returns hunk data.
+ `intra` Boolean that controls intra-line rendering for this attachment.
-review_files({bufnr}) *diffs.nvim.review_files()*
- List the changed files in the review split {bufnr} belongs to, as
- |diffs.ReviewFile| items, or `nil` when {bufnr} is not a review split.
+ Explicit options replace an existing attachment. A hunk provider runs after
+ buffer changes and after |diffs.nvim-refresh()|.
- Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+ Hunk fields:
+ `start_line` 1-based header line number.
+ `lines` Body lines, including diff prefixes.
+ `filename` Source name for diagnostic messages.
+ `ft`, `lang` Filetype and Treesitter language for syntax highlighting.
+ `column_offset` Bytes before the logical diff prefix, default 0.
+ `prefix_width` Diff prefix width.
+ `quote_width` Email quote prefix width.
+ `header_context` Source text in the hunk header.
+ `header_context_col` Byte column of that text, excluding `column_offset`.
+ `header_lines` Additional header lines.
+ `header_start_line` First additional header line.
-review_current({bufnr}) *diffs.nvim.review_current()*
- Return `{ index, count, file }` for the file currently shown in the review
- split {bufnr} belongs to (`file` is a |diffs.ReviewFile|), or `nil`
- otherwise. Handy for statuslines.
+ The buffer row for body line `i` is `start_line + i - 1`, using 0-based rows.
+ Diffs does not highlight columns before `column_offset`.
+ Supplied hunks bypass the parser. Include `ft` or `lang` for syntax colors.
+ Return hunks in buffer order. Include blank entries in `lines` to retain rows.
- Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+detach({bufnr}) *diffs.nvim-detach()*
+ Remove highlighting from the buffer. Omit {bufnr}, or use 0, for the current
+ buffer.
-review_goto({target}, {bufnr}) *diffs.nvim.review_goto()*
- Switch the review split {bufnr} belongs to so it shows {target}, swapping
- both panes in place. Returns `true` on success, `false` when {bufnr} is not
- a review split, no file matches, or {target} is skipped. Never opens a split.
+refresh({bufnr}) *diffs.nvim-refresh()*
+ Recompute highlighting for an attached buffer. Omit {bufnr}, or use 0, for
+ the current buffer.
- Parameters: ~
- {target} (string) A |diffs.ReviewFile| `key` (preferred) or path.
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+highlight_options() *diffs.nvim-highlight_options()*
+ Initialize the highlight groups and return the effective highlight options.
-review_next_file() *diffs.nvim.review_next_file()*
-review_prev_file() *diffs.nvim.review_prev_file()*
- Step the current review split to the next/previous changed file, wrapping
- at the ends and skipping unsupported files. Backing functions for `]q`/`[q`,
- `]f`/`[f`,
- |(diffs-review-next-file)|, and |(diffs-review-prev-file)|.
+diff_options() *diffs.nvim-diff_options()*
+ Return the effective diff options from 'diffopt'.
-review_toggle_layout({opts}) *diffs.nvim.review_toggle_layout()*
- Toggle the current review surface between the generated review map and split
- layout. From a generated review map, opens the selected file in split layout.
- From a review split, closes the pair and restores the generated map layout
- recorded on the split (`unified` or `stacked`). Either direction keeps the
- cursor on the line it was on, mapped through the diff. Returns the opened
- buffer number or `nil` if the current buffer is not a review surface.
+compute_intra_hunks({lines}) *diffs.nvim-compute_intra_hunks()*
+ Compute intra-line spans for unified diff body lines.
- Parameters: ~
- {opts} (table, optional) Supports `{ bufnr = }`. Defaults to
- the current buffer.
-
-select_review_file({bufnr}) *diffs.nvim.select_review_file()*
- Prompt with |vim.ui.select()| over the review split's files and jump to the
- chosen one. Skipped files are shown as `(skipped)` and do not jump. Backing
- function for |(diffs-review-select-file)| (`gO`).
- It uses whatever |vim.ui.select()| backend you have configured, so no extra
- dependency is required. To build a bespoke picker, call the data and action
- functions directly, capturing the review buffer before the picker opens: >lua
- local diffs = require('diffs')
- local bufnr = vim.api.nvim_get_current_buf()
- local files = diffs.review_files(bufnr)
- if files then
- vim.ui.select(files, {
- format_item = function(f)
- return ('[%s] %s'):format(f.section_label or '', f.path)
- end,
- }, function(choice)
- if choice then
- diffs.review_goto(choice.key, bufnr)
- end
- end)
- end
-<
- Parameters: ~
- {bufnr} (integer, optional) Buffer number. Defaults to current buffer.
+whitespace_only_lines({lines}) *diffs.nvim-whitespace_only_lines()*
+ Return a table of lines ignored by the whitespace options in 'diffopt'.
+ Keys are 1-based logical hunk line indices. Each ignored line has value true.
==============================================================================
-KNOWN LIMITATIONS *diffs.nvim-limitations*
-
-Incomplete Syntax Context ~
- *diffs.nvim-syntax-context*
-Treesitter parses each diff hunk in isolation. Hunks that start or end
-mid-expression may produce imperfect highlights.
-
-Syntax Highlighting Flash ~
- *diffs.nvim-flash*
-When opening a fugitive buffer, there is an unavoidable visual "flash" where
-the buffer briefly shows fugitive's default diff highlighting before
-diffs.nvim applies its own highlights.
-
-Conflicting Diff Plugins ~
- *diffs.nvim-plugin-conflicts*
-Plugins that also own diff highlighting, sign columns, or conflict resolution
-can visually conflict with diffs.nvim. Common overlap points include
-diffview.nvim, mini.diff, gitsigns.nvim line highlights, and
-git-conflict.nvim. If you see visual conflicts, disable one plugin's
-overlapping diff features.
-
-Prefixless Diff Path Ambiguity ~
- *diffs.nvim-prefix-ambiguity*
-Diffs that diffs.nvim generates pin git's `a/` and `b/` path prefixes, so they
-are unaffected by `diff.mnemonicPrefix` or `diff.noprefix`. Diffs from other
-sources that diffs.nvim only highlights (fugitive, neogit, an opened patch)
-are parsed as they arrive. When such a diff was produced with `diff.noprefix`,
-a header like `diff --git path path` carries no prefix, and a real top-level
-directory named with a single letter (for example `a` or `i`) cannot be
-distinguished from a stripped prefix. In that narrow case the file may be
-mis-identified, which can degrade its syntax highlighting or hunk navigation.
-
-==============================================================================
-HIGHLIGHT GROUPS *diffs.nvim-highlights*
-
-Diff and generated rail highlights: ~
- *DiffsClear*
- DiffsClear Clears underlying diff foreground colors while preserving
- the normal foreground.
-
- *DiffsAdd*
- DiffsAdd Background for `+` lines.
-
- *DiffsDelete*
- DiffsDelete Background for `-` lines.
-
- *DiffsDim*
- DiffsDim Faint, neutral background for a whitespace-only changed
- line on a decorated (non-generated) surface while
- 'diffopt' ignores whitespace. See
- |diffs.nvim-whitespace|.
-
- *DiffsRail*
- DiffsRail Generated line-number rails and separators.
-
- *DiffsRailNr*
- DiffsRailNr Context line numbers in generated rails.
-
- *DiffsAddRailNr*
- DiffsAddRailNr Add line-number rails on generated `+` lines.
-
- *DiffsDeleteRailNr*
- DiffsDeleteRailNr Delete line-number rails on generated `-` lines.
-
- *DiffsAddBar*
- DiffsAddBar Change bar for `+` lines in diffs.nvim-owned views.
-
- *DiffsDeleteBar*
- DiffsDeleteBar Change bar for `-` lines in diffs.nvim-owned views.
+HIGHLIGHT GROUPS *diffs.nvim-highlights*
- *DiffsAddCount*
- DiffsAddCount Added-line counts in the quickfix file list.
+Diffs derives colors from Normal, DiffAdd, DiffDelete, DiffChange, and DiffText.
+It updates its groups after ColorScheme events.
- *DiffsDeleteCount*
- DiffsDeleteCount Deleted-line counts in the quickfix file list.
-
- *DiffsAddText*
- DiffsAddText Character-level background for changed characters
- within `+` lines.
-
- *DiffsDeleteText*
- DiffsDeleteText Character-level background for changed characters
- within `-` lines.
-
-Conflict highlights: ~
- *DiffsConflictOurs*
- DiffsConflictOurs Background for "ours" (current) content lines.
-
- *DiffsConflictTheirs*
- DiffsConflictTheirs Background for "theirs" (incoming) content lines.
-
- *DiffsConflictBase*
- DiffsConflictBase Background for base (ancestor) content lines in
- diff3 conflicts.
-
- *DiffsConflictMarker*
- DiffsConflictMarker Dimmed foreground with bold for `<<<<<<<`,
- `=======`, `>>>>>>>`, and `|||||||` marker lines.
-
- *DiffsConflictOursNr*
- DiffsConflictOursNr Line number for "ours" content lines.
-
- *DiffsConflictTheirsNr*
- DiffsConflictTheirsNr Line number for "theirs" content lines.
-
- *DiffsConflictBaseNr*
- DiffsConflictBaseNr Line number for base content lines (diff3).
-
-Diff mode window highlights: ~ These are used for 'winhighlight' remapping in
-`&diff` windows.
-
- *DiffsDiffAdd*
- DiffsDiffAdd Background-only replacement for `DiffAdd`.
-
- *DiffsDiffDelete*
- DiffsDiffDelete Replacement for `DiffDelete`.
-
- *DiffsDiffChange*
- DiffsDiffChange Background-only replacement for `DiffChange`.
-
- *DiffsDiffText*
- DiffsDiffText Background-only replacement for `DiffText`.
-
-After diffs.nvim initializes, customize with |nvim_set_hl()| and
-|ColorScheme|: >lua
- local function set_highlights()
- vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#2e4a3a' })
- vim.api.nvim_set_hl(0, 'DiffsDiffDelete', { link = 'DiffDelete' })
- end
+ DiffsClear Base text color
+ DiffsAdd Added line background
+ DiffsDelete Removed line background
+ DiffsAddText Changed text on added lines
+ DiffsDeleteText Changed text on removed lines
+ DiffsDiffAdd Native diff added line background
+ DiffsDiffDelete Native diff deleted line colors
+ DiffsDiffChange Native diff changed line background
+ DiffsDiffText Native diff changed text background
+Set custom colors in a ColorScheme callback after the plugin loads: >lua
vim.api.nvim_create_autocmd('ColorScheme', {
- group = vim.api.nvim_create_augroup('MyDiffsHighlights', { clear = true }),
callback = function()
- vim.schedule(set_highlights)
+ vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = '#173317' })
end,
})
- vim.schedule(set_highlights)
<
-
-diffs.nvim recomputes these groups on |ColorScheme|.
-
==============================================================================
-HEALTH CHECK *diffs.nvim-health*
+LIMITATIONS *diffs.nvim-limitations*
+
+Treesitter parses each hunk separately. Hunks that start or end inside an
+expression can have incomplete syntax highlighting.
-Run `:checkhealth diffs` to verify your setup.
+Vim syntax fallback runs after redraw. A new hunk can appear without syntax
+colors for one frame. Language parsers and queries load on first use.
-Checks performed:
-- Neovim version >= 0.12.0
-- vim-fugitive is installed (optional)
+Other plugins that change diff highlights can produce conflicting colors.
==============================================================================
-ACKNOWLEDGEMENTS *diffs.nvim-acknowledgements*
+HEALTH *diffs.nvim-health*
-- vim-fugitive (https://github.com/tpope/vim-fugitive)
-- diffview.nvim (https://github.com/sindrets/diffview.nvim)
-- @phanen (https://github.com/phanen) - diff header highlighting,
- treesitter injection support, blame_hl.nvim (gitsigns blame popup
- highlighting inspiration)
-- @tris203 (https://github.com/tris203) - support for transparent backgrounds
+Run |:checkhealth| diffs to inspect the Neovim version and the Treesitter diff
+parser. Diffs uses its Lua parser when the Treesitter diff parser is unavailable.
==============================================================================
- vim:tw=78:ts=8:ft=help:norl:
+vim:tw=78:ts=8:ft=help:norl:
diff --git a/lua/diffs/actions.lua b/lua/diffs/actions.lua
deleted file mode 100644
index 39f345a0..00000000
--- a/lua/diffs/actions.lua
+++ /dev/null
@@ -1,476 +0,0 @@
-local M = {}
-
-local diffspec = require('diffs.spec')
-local git = require('diffs.git')
-local hunk_model = require('diffs.hunks')
-local notify = require('diffs.log').notify
-
----@param bufnr integer
----@param name string
----@return any
-local function get_buf_var(bufnr, name)
- local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name)
- if ok then
- return value
- end
- return nil
-end
-
----@param bufnr integer
----@return diffs.DiffHunk[]
-local function buffer_hunks(bufnr)
- local hunks = get_buf_var(bufnr, 'diffs_hunks')
- if type(hunks) == 'table' then
- return hunks
- end
- return {}
-end
-
----@param bufnr integer
----@return diffs.DiffHunk?
-local function hunk_under_cursor(bufnr)
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
- return hunk_model.hunk_at_line(buffer_hunks(bufnr), cursor_line)
-end
-
----@param hunk diffs.DiffHunk
----@return diffs.DiffSpec?
-local function hunk_diff_spec(hunk)
- if not hunk.diff_spec then
- return nil
- end
- local ok, spec = pcall(diffspec.new, hunk.diff_spec)
- if ok then
- return spec
- end
- return nil
-end
-
----@param lines string[]
-local function extend_patch_lines(patch, lines)
- for _, line in ipairs(lines or {}) do
- patch[#patch + 1] = line
- end
-end
-
----@param line diffs.DiffHunkLine
----@return boolean
-local function is_change_line(line)
- return line.kind == 'add' or line.kind == 'delete'
-end
-
----@param line diffs.DiffHunkLine
----@return string
-local function context_text(line)
- return ' ' .. line.text:sub(2)
-end
-
----@param lnum integer
----@param range_start integer
----@param range_finish integer
----@return boolean
-local function in_range(lnum, range_start, range_finish)
- return lnum >= range_start and lnum <= range_finish
-end
-
----@param hunk diffs.DiffHunk
----@return string?, string?
-function M.patch_for_hunk(hunk)
- if not hunk then
- return nil, 'no diff hunk under cursor'
- end
- if type(hunk.file_header_lines) ~= 'table' or #hunk.file_header_lines == 0 then
- return nil, 'cannot build hunk patch without file headers'
- end
-
- local patch_lines = {}
- extend_patch_lines(patch_lines, hunk.file_header_lines)
- for _, line in ipairs(hunk.lines or {}) do
- patch_lines[#patch_lines + 1] = line.text
- end
-
- return table.concat(patch_lines, '\n') .. '\n', nil
-end
-
----@class diffs.DiffChangeGroup
----@field lines diffs.DiffHunkLine[]
----@field has_add boolean
----@field has_delete boolean
-
----@param hunk diffs.DiffHunk
----@return diffs.DiffChangeGroup[]
-local function change_groups(hunk)
- local groups = {}
- local current = nil
-
- local function finish_group()
- if current then
- groups[#groups + 1] = current
- current = nil
- end
- end
-
- for _, line in ipairs(hunk.lines or {}) do
- if is_change_line(line) then
- current = current or { lines = {}, has_add = false, has_delete = false }
- current.lines[#current.lines + 1] = line
- current.has_add = current.has_add or line.kind == 'add'
- current.has_delete = current.has_delete or line.kind == 'delete'
- elseif line.kind ~= 'meta' then
- finish_group()
- end
- end
-
- finish_group()
- return groups
-end
-
----@param hunk diffs.DiffHunk
----@param range_start integer
----@param range_finish integer
----@return string?
-local function validate_range(hunk, range_start, range_finish)
- for _, group in ipairs(change_groups(hunk)) do
- if group.has_add and group.has_delete then
- local selected = 0
- for _, line in ipairs(group.lines) do
- if in_range(line.lnum, range_start, range_finish) then
- selected = selected + 1
- end
- end
- if selected > 0 and selected < #group.lines then
- return 'select the complete replacement group before applying it'
- end
- end
- end
- return nil
-end
-
----@param hunk diffs.DiffHunk
----@param range_start integer
----@param range_finish integer
----@param opts? { target?: "left"|"right" }
----@return string?, string?
-function M.patch_for_range(hunk, range_start, range_finish, opts)
- opts = opts or {}
- local target = opts.target or 'left'
- if not hunk then
- return nil, 'no diff hunk under cursor'
- end
- if target ~= 'left' and target ~= 'right' then
- return nil, 'invalid diff range patch target'
- end
- if type(range_start) ~= 'number' or type(range_finish) ~= 'number' then
- return nil, 'invalid diff visual range'
- end
- if range_finish < range_start then
- range_start, range_finish = range_finish, range_start
- end
- if range_start < hunk.buffer_range.start or range_finish > hunk.buffer_range.finish then
- return nil, 'visual selection must stay within one diff hunk'
- end
-
- local range_err = validate_range(hunk, range_start, range_finish)
- if range_err then
- return nil, range_err
- end
- if type(hunk.file_header_lines) ~= 'table' or #hunk.file_header_lines == 0 then
- return nil, 'cannot build hunk patch without file headers'
- end
-
- local patch_lines = {}
- local has_change = false
- local previous_emitted_change = false
- extend_patch_lines(patch_lines, hunk.file_header_lines)
- patch_lines[#patch_lines + 1] = hunk.header
-
- for _, line in ipairs(hunk.lines or {}) do
- local emitted = nil
- local emitted_change = false
- if line.kind == 'context' or (line.kind == 'meta' and previous_emitted_change) then
- emitted = line.text
- elseif line.kind == 'add' then
- if in_range(line.lnum, range_start, range_finish) then
- emitted = line.text
- emitted_change = true
- has_change = true
- elseif target == 'right' then
- emitted = context_text(line)
- end
- elseif line.kind == 'delete' then
- if in_range(line.lnum, range_start, range_finish) then
- emitted = line.text
- emitted_change = true
- has_change = true
- elseif target == 'left' then
- emitted = context_text(line)
- end
- end
-
- if emitted then
- patch_lines[#patch_lines + 1] = emitted
- end
- previous_emitted_change = emitted_change
- end
-
- if not has_change then
- return nil, 'visual selection does not include changed diff lines'
- end
-
- return table.concat(patch_lines, '\n') .. '\n', nil
-end
-
----@param output string[]?
----@return string
-local function format_git_output(output)
- local message = table.concat(output or {}, '\n')
- if message == '' then
- return 'git apply failed'
- end
- return message
-end
-
----@param repo_root string
----@param patch string
----@param operation "stage"|"unstage"
----@param opts? { recount?: boolean }
----@return boolean, string?
-local function apply(repo_root, patch, operation, opts)
- opts = opts or {}
- local ok, output = git.apply_patch(repo_root, patch, {
- cached = true,
- reverse = operation == 'unstage',
- recount = opts.recount,
- })
- if not ok then
- return false, format_git_output(output)
- end
-
- return true, nil
-end
-
----@param bufnr integer
----@return diffs.DiffHunk?, diffs.DiffSpec?, string?
-local function current_action_context(bufnr)
- local hunk = hunk_under_cursor(bufnr)
- if not hunk then
- return nil, nil, 'no diff hunk under cursor'
- end
-
- local spec = hunk_diff_spec(hunk)
- if not spec then
- return nil, nil, 'cannot resolve diff hunk edge'
- end
-
- return hunk, spec, nil
-end
-
----@param bufnr integer
----@param range_start integer
----@param range_finish integer
----@return diffs.DiffHunk?, diffs.DiffSpec?, string?
-local function range_action_context(bufnr, range_start, range_finish)
- if range_finish < range_start then
- range_start, range_finish = range_finish, range_start
- end
-
- local parsed = buffer_hunks(bufnr)
- local start_hunk = hunk_model.hunk_at_line(parsed, range_start)
- local finish_hunk = hunk_model.hunk_at_line(parsed, range_finish)
- if not start_hunk or not finish_hunk or start_hunk.index ~= finish_hunk.index then
- return nil, nil, 'visual selection must stay within one diff hunk'
- end
-
- local spec = hunk_diff_spec(start_hunk)
- if not spec then
- return nil, nil, 'cannot resolve diff hunk edge'
- end
-
- return start_hunk, spec, nil
-end
-
----@param bufnr integer
----@param hunk diffs.DiffHunk
----@param operation "stage"|"unstage"
----@param opts? { patch?: string, recount?: boolean }
----@return boolean
-local function mutate_hunk(bufnr, hunk, operation, opts)
- opts = opts or {}
- local source = get_buf_var(bufnr, 'diffs_source')
- local repo_root = source and source.repo_root
- if not repo_root then
- notify('cannot mutate diff hunk without a source repository', vim.log.levels.ERROR)
- return false
- end
-
- local patch, patch_err = opts.patch, nil
- if not patch then
- patch, patch_err = M.patch_for_hunk(hunk)
- end
- if not patch then
- notify(patch_err or 'cannot build hunk patch', vim.log.levels.ERROR)
- return false
- end
-
- local ok, err = apply(repo_root, patch, operation, { recount = opts.recount })
- if not ok then
- notify('failed to ' .. operation .. ' diff hunk: ' .. err, vim.log.levels.ERROR)
- return false
- end
-
- return true
-end
-
----@param bufnr integer
----@return boolean
-function M.put_hunk(bufnr)
- local hunk, spec, err = current_action_context(bufnr)
- if err then
- notify(err, vim.log.levels.WARN)
- return false
- end
- if not hunk or not spec then
- notify('cannot resolve diff hunk edge', vim.log.levels.WARN)
- return false
- end
-
- local patch_actions = diffspec.patch_actions(spec)
- if patch_actions.can_put then
- return mutate_hunk(bufnr, hunk, 'stage')
- end
-
- if patch_actions.can_obtain then
- notify('diff hunk is already in the index', vim.log.levels.WARN)
- return false
- end
-
- notify('cannot put read-only diff hunk', vim.log.levels.WARN)
- return false
-end
-
----@param bufnr integer
----@return boolean
-function M.obtain_hunk(bufnr)
- local hunk, spec, err = current_action_context(bufnr)
- if err then
- notify(err, vim.log.levels.WARN)
- return false
- end
- if not hunk or not spec then
- notify('cannot resolve diff hunk edge', vim.log.levels.WARN)
- return false
- end
-
- local patch_actions = diffspec.patch_actions(spec)
- if patch_actions.can_obtain then
- return mutate_hunk(bufnr, hunk, 'unstage')
- end
-
- if patch_actions.can_put then
- notify('restoring worktree hunks is not supported', vim.log.levels.WARN)
- return false
- end
-
- notify('cannot obtain read-only diff hunk', vim.log.levels.WARN)
- return false
-end
-
----@param bufnr integer
----@param range_start integer
----@param range_finish integer
----@return diffs.DiffHunk?, diffs.DiffSpec?, string?
-local function range_context_or_notify(bufnr, range_start, range_finish)
- local hunk, spec, err = range_action_context(bufnr, range_start, range_finish)
- if err then
- notify(err, vim.log.levels.WARN)
- return nil, nil, err
- end
- if not hunk or not spec then
- local fallback = 'cannot resolve diff hunk edge'
- notify(fallback, vim.log.levels.WARN)
- return nil, nil, fallback
- end
-
- return hunk, spec, nil
-end
-
----@param hunk diffs.DiffHunk
----@param range_start integer
----@param range_finish integer
----@param target "left"|"right"
----@return string?
-local function range_patch_or_notify(hunk, range_start, range_finish, target)
- local patch, patch_err = M.patch_for_range(hunk, range_start, range_finish, {
- target = target,
- })
- if not patch then
- notify(patch_err or 'cannot build hunk patch', vim.log.levels.WARN)
- return nil
- end
-
- return patch
-end
-
----@param bufnr integer
----@param range_start integer
----@param range_finish integer
----@return boolean
-function M.put_range(bufnr, range_start, range_finish)
- local hunk, spec = range_context_or_notify(bufnr, range_start, range_finish)
- if not hunk or not spec then
- return false
- end
-
- local patch_actions = diffspec.patch_actions(spec)
- if patch_actions.can_put then
- local patch = range_patch_or_notify(hunk, range_start, range_finish, 'left')
- if not patch then
- return false
- end
- return mutate_hunk(bufnr, hunk, 'stage', {
- patch = patch,
- recount = true,
- })
- end
-
- if patch_actions.can_obtain then
- notify('diff hunk is already in the index', vim.log.levels.WARN)
- return false
- end
-
- notify('cannot put read-only diff hunk', vim.log.levels.WARN)
- return false
-end
-
----@param bufnr integer
----@param range_start integer
----@param range_finish integer
----@return boolean
-function M.obtain_range(bufnr, range_start, range_finish)
- local hunk, spec = range_context_or_notify(bufnr, range_start, range_finish)
- if not hunk or not spec then
- return false
- end
-
- local patch_actions = diffspec.patch_actions(spec)
- if patch_actions.can_obtain then
- local patch = range_patch_or_notify(hunk, range_start, range_finish, 'right')
- if not patch then
- return false
- end
- return mutate_hunk(bufnr, hunk, 'unstage', {
- patch = patch,
- recount = true,
- })
- end
-
- if patch_actions.can_put then
- notify('restoring worktree hunks is not supported', vim.log.levels.WARN)
- return false
- end
-
- notify('cannot obtain read-only diff hunk', vim.log.levels.WARN)
- return false
-end
-
-return M
diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua
deleted file mode 100644
index 93c9f31a..00000000
--- a/lua/diffs/commands.lua
+++ /dev/null
@@ -1,2467 +0,0 @@
-local M = {}
-
-local actions = require('diffs.actions')
-local content = require('diffs.content')
-local diff_parser = require('diffs.diffargs')
-local diffopt = require('diffs.diffopt')
-local diffspec = require('diffs.spec')
-local difftastic = require('diffs.difftastic')
-local generated = require('diffs.generated')
-local git = require('diffs.git')
-local hunk_model = require('diffs.hunks')
-local lists = require('diffs.lists')
-local log = require('diffs.log')
-local rails = require('diffs.rails')
-local render = require('diffs.render')
-local review = require('diffs.review')
-local runtime = require('diffs.runtime')
-local split = require('diffs.split')
-
-local dbg = log.dbg
-local notify = log.notify
-local review_split_group = vim.api.nvim_create_augroup('diffs_review_split', { clear = false })
-
---- Paint difftastic structural intra spans onto a generated unified buffer and
---- emit the formatting-only notice when difft saw no structural change.
----@param diff_buf integer
----@param diff_lines string[]
----@param diff_spec diffs.DiffSpec?
----@param lhs table
----@param rhs table
-local function paint_difft_unified(diff_buf, diff_lines, diff_spec, lhs, rhs)
- difftastic.apply_unified(
- diff_buf,
- lhs,
- rhs,
- diff_lines,
- diff_spec,
- rails.width_for_buffer(diff_buf)
- )
- if not difftastic.has_changes(lhs, rhs) then
- notify('difftastic: no structural changes (formatting only)', vim.log.levels.INFO)
- end
-end
-
---- Apply difftastic to a generated unified buffer built from two content
---- arrays. No-op when difftastic is disabled/unavailable or content is missing.
----@param diff_buf integer
----@param diff_lines string[]
----@param diff_spec diffs.DiffSpec?
----@param old_lines string[]?
----@param new_lines string[]?
----@param relpath string
-local function apply_difft_unified(diff_buf, diff_lines, diff_spec, old_lines, new_lines, relpath)
- if not difftastic.available() or not old_lines or not new_lines then
- return
- end
- local lhs, rhs = difftastic.span_maps_for_content(old_lines, new_lines, relpath)
- if lhs and rhs then
- paint_difft_unified(diff_buf, diff_lines, diff_spec, lhs, rhs)
- end
-end
-
----@class diffs.HunkKeymap
----@field mode string
----@field lhs string
----@field callback function
-
----@type table
-local hunk_keymaps = {}
----@type table
-local hunk_keymap_autocmds = {}
-
----@alias diffs.ReviewMapLayout "unified"|"stacked"
-
----@class diffs.ReviewSplitState
----@field left_buf integer
----@field right_buf integer
----@field left_win integer
----@field right_win integer
----@field review diffs.ReviewSpec
----@field repo_root string
----@field display string
----@field review_lines string[]
----@field list_opts table?
----@field map_layout diffs.ReviewMapLayout
----@field selected_file string
----@field selected_key string?
----@field selected_diff_spec diffs.DiffSpec
----@field autocmds integer[]
-
----@type table
-local review_split_states = {}
-
----@param bufnr integer
----@param mode string
----@param lhs string
----@return table?
-local function get_buffer_keymap(bufnr, mode, lhs)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do
- if keymap.lhs == lhs then
- return keymap
- end
- end
- return nil
-end
-
----@param bufnr integer
-local function clear_hunk_keymaps(bufnr)
- local registered = hunk_keymaps[bufnr]
- if not registered then
- return
- end
- for _, keymap in ipairs(registered) do
- local current = get_buffer_keymap(bufnr, keymap.mode, keymap.lhs)
- if current and current.callback == keymap.callback then
- pcall(vim.keymap.del, keymap.mode, keymap.lhs, { buffer = bufnr })
- end
- end
- hunk_keymaps[bufnr] = nil
-end
-
----@param bufnr integer
-local function ensure_hunk_keymap_cleanup(bufnr)
- if hunk_keymap_autocmds[bufnr] then
- return
- end
-
- hunk_keymap_autocmds[bufnr] = vim.api.nvim_create_autocmd('BufWipeout', {
- buffer = bufnr,
- once = true,
- callback = function()
- hunk_keymaps[bufnr] = nil
- hunk_keymap_autocmds[bufnr] = nil
- end,
- })
-end
-
----@param bufnr integer
----@param mode string
----@param lhs string
----@param callback function
----@param desc string
-local function set_hunk_keymap(bufnr, mode, lhs, callback, desc)
- if get_buffer_keymap(bufnr, mode, lhs) then
- return
- end
-
- vim.keymap.set(mode, lhs, callback, { buffer = bufnr, desc = desc })
- hunk_keymaps[bufnr] = hunk_keymaps[bufnr] or {}
- hunk_keymaps[bufnr][#hunk_keymaps[bufnr] + 1] = {
- mode = mode,
- lhs = lhs,
- callback = callback,
- }
-end
-
----@param bufnr integer
----@return integer, integer
-local function visual_range(bufnr)
- local start_line = vim.api.nvim_buf_get_mark(bufnr, '<')[1]
- local finish_line = vim.api.nvim_buf_get_mark(bufnr, '>')[1]
- return math.min(start_line, finish_line), math.max(start_line, finish_line)
-end
-
----@return integer?
-function M.find_diffs_window()
- local tabpage = vim.api.nvim_get_current_tabpage()
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
- if vim.api.nvim_win_is_valid(win) then
- local buf = vim.api.nvim_win_get_buf(win)
- local name = vim.api.nvim_buf_get_name(buf)
- if name:match('^diffs://') then
- return win
- end
- end
- end
- return nil
-end
-
----@param bufnr integer
----@return integer?
-local function first_window_for_buffer(bufnr)
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then
- return win
- end
- end
- return nil
-end
-
----@param bufnr integer
-function M.setup_diff_buf(bufnr)
- if not get_buffer_keymap(bufnr, 'n', '') then
- vim.keymap.set(
- 'n',
- '',
- 'nohlsearchdiffupdate(diffs-refresh)',
- { buffer = bufnr, silent = true, desc = 'Refresh diff and redraw' }
- )
- end
- if not get_buffer_keymap(bufnr, 'n', 'q') then
- vim.keymap.set('n', 'q', 'close', { buffer = bufnr })
- end
- local has_hunks, parsed_hunks = generated.raw_hunks(bufnr)
- if not has_hunks then
- clear_hunk_keymaps(bufnr)
- return
- end
-
- clear_hunk_keymaps(bufnr)
- local can_put = false
- local can_obtain = false
- for _, hunk in ipairs(type(parsed_hunks) == 'table' and parsed_hunks or {}) do
- can_put = can_put or hunk.can_put == true
- can_obtain = can_obtain or hunk.can_obtain == true
- end
-
- set_hunk_keymap(bufnr, 'n', ']c', function()
- hunk_model.goto_next(bufnr)
- end, 'Next diff hunk')
- set_hunk_keymap(bufnr, 'n', '[c', function()
- hunk_model.goto_prev(bufnr)
- end, 'Previous diff hunk')
- set_hunk_keymap(bufnr, 'n', '', function()
- hunk_model.open_source(bufnr)
- end, 'Open source file')
- if can_obtain then
- set_hunk_keymap(bufnr, 'n', 'do', function()
- if actions.obtain_hunk(bufnr) then
- M.read_buffer(bufnr)
- end
- end, 'Unstage diff hunk')
- set_hunk_keymap(bufnr, 'x', 'do', function()
- local range_start, range_finish = visual_range(bufnr)
- if actions.obtain_range(bufnr, range_start, range_finish) then
- M.read_buffer(bufnr)
- end
- end, 'Unstage selected diff lines')
- end
- if can_put then
- set_hunk_keymap(bufnr, 'n', 'dp', function()
- if actions.put_hunk(bufnr) then
- M.read_buffer(bufnr)
- end
- end, 'Stage diff hunk')
- set_hunk_keymap(bufnr, 'x', 'dp', function()
- local range_start, range_finish = visual_range(bufnr)
- if actions.put_range(bufnr, range_start, range_finish) then
- M.read_buffer(bufnr)
- end
- end, 'Stage selected diff lines')
- end
-
- ensure_hunk_keymap_cleanup(bufnr)
-end
-
----@param diff_lines string[]
----@param hunk_position { hunk_header: string, offset: integer }
----@return integer?
-function M.find_hunk_line(diff_lines, hunk_position)
- for i, line in ipairs(diff_lines) do
- if line == hunk_position.hunk_header then
- return i + hunk_position.offset
- end
- end
- return nil
-end
-
----@param lines string[]
----@return string[]
-function M.filter_combined_diffs(lines)
- local result = {}
- local skip = false
- for _, line in ipairs(lines) do
- if line:match('^diff %-%-cc ') then
- skip = true
- elseif line:match('^diff %-%-git ') then
- skip = false
- end
- if not skip then
- table.insert(result, line)
- end
- end
- return result
-end
-
----@param diff_spec diffs.DiffSpec
----@return string
-local function diff_buffer_label(diff_spec)
- diff_spec = diffspec.new(diff_spec)
- local left = diff_spec.left
- local right = diff_spec.right
-
- if
- left.kind == diffspec.endpoint_kind.index and right.kind == diffspec.endpoint_kind.worktree
- then
- return 'unstaged'
- end
-
- if
- left.kind == diffspec.endpoint_kind.tree
- and left.rev == 'HEAD'
- and right.kind == diffspec.endpoint_kind.index
- then
- return 'staged'
- end
-
- if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.worktree then
- return left.rev
- end
-
- if
- left.kind == diffspec.endpoint_kind.stage and right.kind == diffspec.endpoint_kind.worktree
- then
- return 'stage' .. left.stage
- end
-
- return diffspec.label(diff_spec)
-end
-
----@param bufnr integer
----@param diff_lines string[]
----@param diff_spec diffs.DiffSpec
-local function set_diff_hunks_var(bufnr, diff_lines, diff_spec)
- generated.set_hunks_from_lines(bufnr, diff_lines, diff_spec)
-end
-
----@param bufnr integer
----@param info diffs.RailInfo?
-local function set_diff_rails_var(bufnr, info)
- if info then
- generated.set_var(bufnr, 'diffs_rail_width', info.prefix_width)
- if info.style == 'single' or info.style == 'dual' then
- generated.set_var(bufnr, 'diffs_rail_style', info.style)
- else
- generated.del_var(bufnr, 'diffs_rail_style')
- end
- else
- generated.del_var(bufnr, 'diffs_rail_width')
- generated.del_var(bufnr, 'diffs_rail_style')
- end
-end
-
----@param bufnr integer
-local function clear_diff_hunks_var(bufnr)
- generated.clear_hunks(bufnr)
-end
-
----@param bufnr integer
----@return diffs.GeneratedBufferSource?, string?
-local function get_source_var(bufnr)
- return generated.source(bufnr)
-end
-
----@param bufnr integer
-local function set_generated_diff_buffer_options(bufnr)
- vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
- vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr })
- vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
- vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
-end
-
----@param bufnr integer
-local function set_generated_diff_buffer_filetype(bufnr)
- vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
-end
-
----@param win integer
-local function clear_generated_diff_window_bindings(win)
- if not vim.api.nvim_win_is_valid(win) then
- return
- end
-
- vim.api.nvim_set_option_value('scrollbind', false, { win = win })
- vim.api.nvim_set_option_value('cursorbind', false, { win = win })
-end
-
----@class diffs.GeneratedDiffBufferOpts
----@field name string
----@field lines string[]
----@field repo_root? string
----@field diff_spec? diffs.DiffSpec
----@field source? diffs.GeneratedBufferSource
----@field vars? table
----@field rail_style? diffs.RailStyle
-
----@param opts diffs.GeneratedDiffBufferOpts
----@return integer
-local function create_generated_diff_buffer(opts)
- local bufnr = vim.api.nvim_create_buf(false, true)
- local display_lines, rail_info = rails.annotate(opts.lines, {
- rail_style = opts.rail_style,
- })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines)
- vim.api.nvim_buf_set_name(bufnr, opts.name)
- set_diff_rails_var(bufnr, rail_info)
-
- if opts.diff_spec then
- set_diff_hunks_var(bufnr, opts.lines, opts.diff_spec)
- end
- if opts.source then
- generated.set_source(bufnr, opts.source)
- end
- for name, value in pairs(opts.vars or {}) do
- if value ~= nil then
- generated.set_var(bufnr, name, value)
- end
- end
-
- set_generated_diff_buffer_options(bufnr)
- set_generated_diff_buffer_filetype(bufnr)
-
- return bufnr
-end
-
----@param bufnr integer
----@param vertical? boolean
----@param replace_win? integer
----@return boolean
-local function show_generated_diff_buffer(bufnr, vertical, replace_win)
- if replace_win and not vim.api.nvim_win_is_valid(replace_win) then
- notify('replacement diff window is no longer valid', vim.log.levels.WARN)
- return false
- end
-
- local existing_win = replace_win or M.find_diffs_window()
- if existing_win then
- vim.api.nvim_set_current_win(existing_win)
- split.release_pair_window_options(existing_win)
- clear_generated_diff_window_bindings(existing_win)
- vim.api.nvim_win_set_buf(existing_win, bufnr)
- else
- vim.cmd(vertical and 'vsplit' or 'split')
- clear_generated_diff_window_bindings(vim.api.nvim_get_current_win())
- vim.api.nvim_win_set_buf(0, bufnr)
- end
- return true
-end
-
----@param bufnr integer
----@param diff_lines string[]
----@param diff_spec? diffs.DiffSpec
----@param opts? { rail_style?: diffs.RailStyle }
-local function replace_generated_diff_buffer_lines(bufnr, diff_lines, diff_spec, opts)
- opts = opts or {}
- difftastic.clear_active(bufnr)
- local display_lines, rail_info = rails.annotate(diff_lines, {
- rail_style = opts.rail_style or rails.style_for_buffer(bufnr),
- })
- vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, display_lines)
- set_diff_rails_var(bufnr, rail_info)
-
- if diff_spec then
- set_diff_hunks_var(bufnr, diff_lines, diff_spec)
- else
- clear_diff_hunks_var(bufnr)
- end
-
- set_generated_diff_buffer_options(bufnr)
- set_generated_diff_buffer_filetype(bufnr)
- runtime.refresh(bufnr)
-end
-
----@param bufnr integer
-local function attach_generated_diff_buffer(bufnr)
- M.setup_diff_buf(bufnr)
-
- vim.schedule(function()
- runtime.attach(bufnr)
- end)
-end
-
----@param raw_lines string[]
----@param repo_root string
----@return string[]
-local function replace_combined_diffs(raw_lines, repo_root)
- local unmerged_files = {}
- for _, line in ipairs(raw_lines) do
- local cc_file = line:match('^diff %-%-cc (.+)$')
- if cc_file then
- table.insert(unmerged_files, cc_file)
- end
- end
-
- local result = M.filter_combined_diffs(raw_lines)
-
- for _, filename in ipairs(unmerged_files) do
- local filepath = repo_root .. '/' .. filename
- local old_lines = git.get_file_content(':2', filepath) or {}
- local new_lines = git.get_file_content(':3', filepath) or {}
- local diff_lines = render.unified_lines(old_lines, new_lines, filename, filename)
- for _, dl in ipairs(diff_lines) do
- table.insert(result, dl)
- end
- end
-
- return result
-end
-
----@class diffs.ReviewDepsOpts
----@field rail_style? diffs.RailStyle
----@field review_layout? diffs.ReviewMapLayout
----@field replace_win? integer
-
----@param layout? string
----@return diffs.ReviewMapLayout
-local function normalize_review_map_layout(layout)
- return layout == 'stacked' and 'stacked' or 'unified'
-end
-
----@param rail_style? diffs.RailStyle
----@return diffs.ReviewMapLayout
-local function review_layout_for_rail_style(rail_style)
- return rail_style == 'single' and 'stacked' or 'unified'
-end
-
----@param bufnr integer
----@param display string
----@param layout diffs.ReviewMapLayout
-local function setup_review_map_buffer(bufnr, display, layout)
- generated.set_var(bufnr, 'diffs_review', { display = display, layout = layout })
- if not get_buffer_keymap(bufnr, 'n', 'gs') then
- vim.keymap.set(
- 'n',
- 'gs',
- '(diffs-review-toggle-layout)',
- { buffer = bufnr, remap = true }
- )
- end
-end
-
----@param opts? diffs.ReviewDepsOpts
----@return diffs.ReviewDeps
-local function review_deps(opts)
- opts = opts or {}
- return {
- create_generated_diff_buffer = create_generated_diff_buffer,
- show_generated_diff_buffer = function(bufnr, vertical)
- return show_generated_diff_buffer(bufnr, vertical, opts.replace_win)
- end,
- attach_generated_diff_buffer = attach_generated_diff_buffer,
- replace_combined_diffs = replace_combined_diffs,
- rail_style = opts.rail_style,
- review_layout = opts.review_layout or review_layout_for_rail_style(opts.rail_style),
- setup_review_map_buffer = setup_review_map_buffer,
- }
-end
-
----@param repo_root string
----@param section "staged"|"unstaged"
----@return string[]
-local function render_section_source(repo_root, section)
- local cmd = {
- 'git',
- '-C',
- repo_root,
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- }
- vim.list_extend(cmd, diffopt.git_flags())
- if section == 'staged' then
- table.insert(cmd, '--cached')
- end
-
- local diff_lines = vim.fn.systemlist(cmd)
- if vim.v.shell_error ~= 0 then
- diff_lines = {}
- end
-
- return replace_combined_diffs(diff_lines, repo_root)
-end
-
----@param name string
----@param buffered boolean
----@return string
-local function files_side_display(name, buffered)
- return buffered and ('buffer:' .. name) or name
-end
-
----@param source diffs.GeneratedBufferSource
----@return diffs.ContentLines?, boolean
-local function files_source_new_lines(source)
- local bufnr = source.right_buf
- if
- bufnr
- and vim.api.nvim_buf_is_loaded(bufnr)
- and vim.api.nvim_buf_get_name(bufnr) == source.right_path
- then
- return content.from_buffer(bufnr), true
- end
- return git.get_working_content(source.right_path), false
-end
-
----@param source diffs.GeneratedBufferSource
----@return string[]?, diffs.DiffSpec?, string?, table?, diffs.ContentLines|string[]?, diffs.ContentLines|string[]?
-local function render_source(source)
- if source.kind == 'file' then
- local diff_lines, read_err, old_lines, new_lines = render.file(source.spec, source.repo_root, {
- worktree_buf = source.worktree_buf,
- empty_on_missing = true,
- })
- if not diff_lines then
- return nil, nil, read_err, nil
- end
- return diff_lines, source.spec, diffspec.label(source.spec), nil, old_lines, new_lines
- end
-
- if source.kind == 'files' then
- local old_lines = git.get_working_content(source.left_path)
- if not old_lines then
- return nil, nil, source.left_name .. ': file not readable', nil
- end
- local new_lines, buffered = files_source_new_lines(source)
- if not new_lines then
- return nil, nil, source.right_name .. ': file not readable', nil
- end
- return render.unified_lines(old_lines, new_lines, source.left_name, source.right_name),
- nil,
- 'files:' .. source.left_name .. ' -> ' .. files_side_display(source.right_name, buffered),
- nil,
- old_lines,
- new_lines
- end
-
- if source.kind == 'file_pair' then
- local abs_path = source.repo_root .. '/' .. source.path
- local old_abs_path = source.repo_root .. '/' .. source.old_path
- local old_lines, new_lines
- if source.edge == 'staged' then
- old_lines = git.get_file_content('HEAD', old_abs_path) or {}
- new_lines = git.get_index_content(abs_path) or {}
- else
- old_lines = git.get_index_content(old_abs_path)
- if not old_lines then
- old_lines = git.get_file_content('HEAD', old_abs_path) or {}
- end
- new_lines = git.get_working_content(abs_path) or {}
- end
- return render.unified_lines(old_lines, new_lines, source.old_path, source.path),
- nil,
- source.edge .. ':' .. source.path,
- nil,
- old_lines,
- new_lines
- end
-
- if source.kind == 'section' then
- return render_section_source(source.repo_root, source.section),
- nil,
- source.section .. ':all',
- nil
- end
-
- if source.kind == 'review' then
- local review_lines, review_err, list_opts = review.reload_source(source, review_deps())
- if not review_lines then
- return nil, nil, review_err, nil
- end
- return review_lines, nil, 'review', list_opts
- end
-
- local abs_path = source.repo_root .. '/' .. source.path
- local old_lines = git.get_file_content(':2', abs_path) or {}
- local new_lines = git.get_file_content(':3', abs_path) or {}
- return render.unified_lines(old_lines, new_lines, source.path, source.path),
- nil,
- 'unmerged:' .. source.path,
- nil
-end
-
----@param diff_label string
----@param rel_path string
----@param old_rel_path string
----@return string
-local function file_pair_label(diff_label, rel_path, old_rel_path)
- return diff_label .. ' rename/copy ' .. old_rel_path .. ' -> ' .. rel_path
-end
-
----@param spec? diffs.ReviewSpec
----@param opts? diffs.ReviewDepsOpts
----@return integer?
-function M.review(spec, opts)
- return review.open(spec, review_deps(opts))
-end
-
----@param buf integer
----@param lnum integer
----@return string?
-function M.review_file_at_line(buf, lnum)
- return review.file_at_line(buf, lnum)
-end
-
-local layout_options = {
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
-}
-
-local untracked_options = {
- '++nountracked',
-}
-
----@param layout? "unified"|"stacked"|"split"
----@return diffs.RailStyle
-local function rail_style_for_layout(layout)
- if layout == 'stacked' then
- return 'single'
- end
- return 'dual'
-end
-
-local function warn_vertical_split_ignored()
- notify(
- '++layout=split ignores the :vertical modifier; the split layout manages its own windows',
- vim.log.levels.WARN
- )
-end
-
--- Always-available object literals offered in completion. Merge-stage objects
--- (`:1:%`/`:2:%`/`:3:%`) are valid only during a conflict, so they are accepted
--- by the parser but not advertised here.
-local diff_objects = {
- ':',
- ':%',
- ':0:%',
- '@:%',
-}
-
-local command_names = {
- Diff = true,
-}
-
----@param value string
----@param prefix string
----@return boolean
-local function starts_with(value, prefix)
- return value:find(prefix, 1, true) == 1
-end
-
----@param candidates string[]
----@param arglead string
----@return string[]
-local function prefix_matches(candidates, arglead)
- local matches = {}
- for _, candidate in ipairs(candidates) do
- if starts_with(candidate, arglead) then
- matches[#matches + 1] = candidate
- end
- end
- return matches
-end
-
----@param arglead string
----@return string[]
-local function complete_diff_object(arglead)
- local matches = prefix_matches(diff_objects, arglead)
- for _, ref in ipairs(review.complete(arglead)) do
- if not ref:find('..', 1, true) then
- matches[#matches + 1] = ref
- end
- end
- return matches
-end
-
----@param arglead string
----@param cmdline? string
----@param cursorpos? integer
----@return string[] # completed argument tokens before the cursor, excluding the command name
-local function command_arg_tokens(arglead, cmdline, cursorpos)
- local before = cmdline or ''
- if type(cursorpos) == 'number' and cursorpos > 0 then
- before = before:sub(1, cursorpos)
- end
- if arglead ~= '' and before:sub(-#arglead) == arglead then
- before = before:sub(1, #before - #arglead)
- end
-
- local tokens = vim.split(vim.trim(before), '%s+', { trimempty = true })
- local command_index = 0
- for i = #tokens, 1, -1 do
- if command_names[tokens[i]] then
- command_index = i
- break
- end
- end
- if command_index == 0 and #tokens > 0 then
- command_index = 1
- end
-
- local args = {}
- for i = command_index + 1, #tokens do
- args[#args + 1] = tokens[i]
- end
- return args
-end
-
----@param args string[]
----@param from? integer # first argument index to scan (defaults to 1)
----@return { has_layout: boolean, has_untracked: boolean, has_value: boolean }
-local function args_context(args, from)
- local has_layout = false
- local has_untracked = false
- local has_value = false
- for i = from or 1, #args do
- local token = args[i]
- if token:match('^%+%+layout=') then
- has_layout = true
- elseif token == '++nountracked' then
- has_untracked = true
- elseif not token:match('^%+%+') then
- has_value = true
- end
- end
- return {
- has_layout = has_layout,
- has_untracked = has_untracked,
- has_value = has_value,
- }
-end
-
----@param arglead string
----@param cmdline? string
----@param cursorpos? integer
----@return { has_layout: boolean, has_untracked: boolean, has_value: boolean }
-local function completion_context(arglead, cmdline, cursorpos)
- return args_context(command_arg_tokens(arglead, cmdline, cursorpos))
-end
-
----@param arglead string
----@param cmdline? string
----@param cursorpos? integer
----@return string[]
-local function complete_diff_args(arglead, cmdline, cursorpos)
- local context = completion_context(arglead, cmdline, cursorpos)
- if context.has_value then
- return {}
- end
- if arglead:match('^%+%+') then
- if context.has_layout then
- return {}
- end
- return prefix_matches(layout_options, arglead)
- end
- local matches = {}
- if arglead == '' and not context.has_layout then
- vim.list_extend(matches, layout_options)
- end
- vim.list_extend(matches, complete_diff_object(arglead))
- return matches
-end
-
----@param arglead string
----@param context { has_layout: boolean, has_untracked: boolean, has_value: boolean }
----@return string[]
-local function complete_review_args(arglead, context)
- if context.has_value then
- return review.complete(arglead)
- end
- if arglead:match('^%+%+') then
- local matches = {}
- if not context.has_layout then
- vim.list_extend(matches, prefix_matches(layout_options, arglead))
- end
- if not context.has_untracked then
- vim.list_extend(matches, prefix_matches(untracked_options, arglead))
- end
- vim.list_extend(matches, review.complete(arglead))
- return matches
- end
- local matches = {}
- if arglead == '' and not context.has_layout then
- vim.list_extend(matches, layout_options)
- end
- if arglead == '' and not context.has_untracked then
- vim.list_extend(matches, untracked_options)
- end
- vim.list_extend(matches, review.complete(arglead))
- return matches
-end
-
-local files_layout_options = {
- '++layout=unified',
- '++layout=stacked',
-}
-
----@param arglead string
----@return string[]
-local function complete_files_args(arglead)
- if arglead:match('^%+%+') then
- return prefix_matches(files_layout_options, arglead)
- end
- return vim.fn.getcompletion(arglead, 'file')
-end
-
----@param arglead string
----@param cmdline? string
----@param cursorpos? integer
----@return string[]
-local function complete_diff_command(arglead, cmdline, cursorpos)
- local args = command_arg_tokens(arglead, cmdline, cursorpos)
- if args[1] == 'review' then
- return complete_review_args(arglead, args_context(args, 2))
- end
- if args[1] == 'files' then
- return complete_files_args(arglead)
- end
- local matches = {}
- if #args == 0 and not arglead:match('^%+%+') then
- if starts_with('review', arglead) then
- matches[#matches + 1] = 'review'
- end
- if starts_with('files', arglead) then
- matches[#matches + 1] = 'files'
- end
- end
- vim.list_extend(matches, complete_diff_args(arglead, cmdline, cursorpos))
- return matches
-end
-
----@class diffs.OpenReviewSplitOpts
----@field selection? diffs.GeneratedFileSelection
----@field source_position? diffs.SourcePosition
----@field replace_win? integer
----@field map_layout? diffs.ReviewMapLayout
-
----@type fun(spec?: diffs.ReviewSpec, opts?: diffs.OpenReviewSplitOpts): integer?
-local open_review_split
-
----@param args? string
----@param vertical? boolean
----@param opts? { warn_vertical_split?: boolean }
----@return integer?
-function M.review_command(args, vertical, opts)
- opts = opts or {}
- local parsed = review.parse_command_args(args)
-
- if parsed.layout == 'split' then
- if opts.warn_vertical_split then
- warn_vertical_split_ignored()
- end
- return open_review_split(parsed.spec)
- end
-
- parsed.spec.vertical = vertical or false
- local bufnr = M.review(parsed.spec, {
- rail_style = rail_style_for_layout(parsed.layout),
- review_layout = normalize_review_map_layout(parsed.layout),
- })
- return bufnr
-end
-
----@class diffs.ReviewOpts
----@field layout? "unified"|"stacked"|"split"
----@field replace_win? integer
-
---- Open a review from Lua, for a caller that already knows what it wants
---- reviewed.
----
---- `replace_win` is why this exists as well as `:Diff review`: a command has
---- nowhere to say "this window", so with no review open it splits. An embedder
---- handing a review off from its own buffer already has a window and wants that
---- one, not a new one and not the rest of the tab closed to compensate.
----@param spec? diffs.ReviewSpec
----@param opts? diffs.ReviewOpts
----@return integer?
-function M.open_review(spec, opts)
- opts = opts or {}
- if opts.layout == 'split' then
- return open_review_split(spec, { replace_win = opts.replace_win })
- end
- return M.review(spec, {
- rail_style = rail_style_for_layout(opts.layout),
- review_layout = normalize_review_map_layout(opts.layout),
- replace_win = opts.replace_win,
- })
-end
-
---- Primary `:Diff` command handler. Routes `:Diff review ...` to the review
---- surface and everything else to the current-file diff, threading the
---- `:vertical` modifier through to generated layouts.
----@param args? string
----@param vertical? boolean
----@return integer?
-function M.diff_command(args, vertical)
- vertical = vertical or false
- if args then
- local sub, remainder = args:match('^%s*(%S+)%s*(.*)$')
- if sub == 'review' then
- return M.review_command(
- remainder ~= '' and remainder or nil,
- vertical,
- { warn_vertical_split = vertical }
- )
- end
- if sub == 'files' then
- return M.diff_files_command(remainder ~= '' and remainder or nil, vertical)
- end
- end
- return M.diff(args, vertical, { warn_vertical_split = vertical })
-end
-
----@param args? string
----@param vertical? boolean
----@return integer?
-function M.diff_files_command(args, vertical)
- local parsed, err = diff_parser.parse_files(args)
- if not parsed then
- notify(err, vim.log.levels.ERROR)
- return nil
- end
-
- return M.diff_files(parsed.left, parsed.right, {
- layout = parsed.layout,
- vertical = vertical,
- })
-end
-
----@class diffs.DiffFilesViewOpts
----@field layout "unified"|"stacked"|"split"
----@field vertical? boolean
-
----@param path string
----@return string, string
-local function files_side_names(path)
- local abs = vim.fn.fnamemodify(vim.fn.expand(path), ':p')
- local name = vim.fn.fnamemodify(abs, ':~:.')
- return abs, name ~= '' and name or abs
-end
-
----@param left string?
----@param right string?
----@param opts? diffs.DiffFilesViewOpts
----@return integer?
-function M.diff_files(left, right, opts)
- opts = opts or {}
-
- local current_buf = vim.api.nvim_get_current_buf()
- local current_name = vim.api.nvim_buf_get_name(current_buf)
- local right_buf = right == nil and current_buf or nil
- if (left == nil or right == nil) and current_name == '' then
- notify('cannot diff unnamed buffer', vim.log.levels.ERROR)
- return nil
- end
-
- local left_abs, left_name = files_side_names(left or current_name)
- local right_abs, right_name = files_side_names(right or current_name)
- local right_display = files_side_display(right_name, right_buf ~= nil)
-
- for _, side in ipairs({
- { abs = left_abs, name = left_name, buffered = false },
- { abs = right_abs, name = right_name, buffered = right_buf ~= nil },
- }) do
- if vim.fn.isdirectory(side.abs) == 1 then
- notify(side.name .. ' is a directory; :Diff files compares two files', vim.log.levels.ERROR)
- return nil
- end
- if not side.buffered and vim.fn.filereadable(side.abs) ~= 1 then
- notify(side.name .. ': file not readable', vim.log.levels.ERROR)
- return nil
- end
- end
-
- local old_lines = git.get_working_content(left_abs) or {}
- local new_lines = right_buf and content.from_buffer(right_buf)
- or git.get_working_content(right_abs)
- or {}
- if render.has_binary_lines(old_lines) or render.has_binary_lines(new_lines) then
- notify('diff does not support binary files', vim.log.levels.ERROR)
- return nil
- end
-
- local diff_lines = render.unified_lines(old_lines, new_lines, left_name, right_name)
- if #diff_lines == 0 then
- notify('no changes between ' .. left_name .. ' and ' .. right_display, vim.log.levels.INFO)
- return nil
- end
-
- local diff_buf = create_generated_diff_buffer({
- name = 'diffs://files:' .. left_name .. ' -> ' .. right_display,
- lines = diff_lines,
- source = generated.files_source(left_abs, right_abs, left_name, right_name, right_buf),
- rail_style = rail_style_for_layout(opts.layout),
- })
- show_generated_diff_buffer(diff_buf, opts.vertical)
- lists.set_for_unified_buffer(diff_buf, diff_lines, {
- title = 'diff: files ' .. left_name .. ' -> ' .. right_display,
- })
- attach_generated_diff_buffer(diff_buf)
-
- if right_buf then
- apply_difft_unified(diff_buf, diff_lines, nil, old_lines, new_lines, right_name)
- elseif difftastic.available() then
- local lhs, rhs = difftastic.span_maps_for_paths(left_abs, right_abs)
- if lhs and rhs then
- paint_difft_unified(diff_buf, diff_lines, nil, lhs, rhs)
- end
- end
-
- dbg('opened files diff buffer %d (%s -> %s)', diff_buf, left_name, right_display)
- return diff_buf
-end
-
----@param repo_root string
----@param path string
----@return string?
-local function filetype_for_path(repo_root, path)
- local filepath = repo_root .. '/' .. path
- local existing_buf = vim.fn.bufnr(filepath)
- if existing_buf ~= -1 then
- local ft = vim.api.nvim_get_option_value('filetype', { buf = existing_buf })
- if ft and ft ~= '' then
- return ft
- end
- end
-
- local ft = vim.filetype.match({ filename = path })
- if ft and ft ~= '' then
- return ft
- end
- return nil
-end
-
----@param win integer?
-local function restore_window(win)
- if type(win) == 'number' and vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_set_current_win(win)
- end
-end
-
----@class diffs.ReviewSplitOpts
----@field bufnr? integer
----@field lnum? integer
----@field item? table
-
----@class diffs.ReviewSplitContext
----@field review_buf integer
----@field replace_win integer
----@field selected diffs.GeneratedFileSelection
----@field source_position? diffs.SourcePosition
----@field repo_root string
----@field map_layout diffs.ReviewMapLayout
-
----@param opts? diffs.ReviewSplitOpts
----@return diffs.ReviewSplitContext?, string?, integer?, integer?
-local function review_split_context(opts)
- opts = opts or {}
-
- ---@type diffs.GeneratedFileSelectionOpts
- local selection_opts = {
- bufnr = opts.bufnr,
- lnum = opts.lnum,
- item = opts.item,
- }
- local selected, select_err = lists.selected_generated_file(selection_opts)
- if not selected then
- return nil, select_err or 'no review file selected', vim.log.levels.WARN, nil
- end
-
- local review_buf = selected.bufnr
- if not vim.api.nvim_buf_is_valid(review_buf) then
- return nil, 'selected review buffer is no longer valid', vim.log.levels.WARN, review_buf
- end
-
- local source, source_err = get_source_var(review_buf)
- if source_err then
- return nil,
- 'invalid diffs_source metadata: ' .. tostring(source_err),
- vim.log.levels.WARN,
- review_buf
- end
- if not source or source.kind ~= 'review' then
- return nil, 'selected file is not from a review buffer', vim.log.levels.WARN, nil
- end
- local marker = vim.b[review_buf].diffs_review
- local map_layout = normalize_review_map_layout(type(marker) == 'table' and marker.layout or nil)
-
- local review_win = first_window_for_buffer(review_buf)
- if not review_win then
- return nil,
- 'selected review buffer is not visible; open the review buffer before splitting it',
- vim.log.levels.WARN,
- review_buf
- end
-
- local selected_line = selected.lnum
- and hunk_model.line_at(lists.generated_hunks(review_buf), selected.lnum)
-
- return {
- review_buf = review_buf,
- replace_win = review_win,
- selected = selected,
- source_position = hunk_model.source_position_for(selected_line),
- repo_root = source.repo_root,
- map_layout = map_layout,
- },
- nil,
- nil,
- review_buf
-end
-
----@param review_spec diffs.ReviewSpec
----@param repo_root string
----@param selected diffs.GeneratedFileSelection
----@return diffs.DiffSpec?, diffs.NormalizedReview?, string?
-local function selected_review_diff_spec(review_spec, repo_root, selected)
- return review.diff_spec_for_file(review_spec, repo_root, selected.file, selected)
-end
-
----@param review_spec diffs.ReviewSpec
----@param repo_root string
----@param selected diffs.GeneratedFileSelection
----@return diffs.DiffSpec?, string[]?, string?, integer?
-local function render_review_file_selection(review_spec, repo_root, selected)
- local diff_spec, _, spec_err = selected_review_diff_spec(review_spec, repo_root, selected)
- if not diff_spec then
- return nil, nil, spec_err or 'cannot build review split diff spec', vim.log.levels.ERROR
- end
-
- local diff_lines, render_err = render.file(diff_spec, repo_root, { empty_on_missing = true })
- if not diff_lines then
- return nil, nil, render_err or 'cannot render review split file', vim.log.levels.ERROR
- end
- if #diff_lines == 0 then
- return nil, nil, 'no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO
- end
- return diff_spec, diff_lines, nil, nil
-end
-
----@param list_opts table?
----@return diffs.GeneratedListOptions
-local function review_generated_list_opts(list_opts)
- return {
- metadata_for_line = list_opts and list_opts.metadata_for_line,
- sections = list_opts and list_opts.sections,
- store_hunks = list_opts and list_opts.store_hunks,
- is_skipped = list_opts and list_opts.is_skipped,
- }
-end
-
----@param review_spec diffs.ReviewSpec
----@param repo_root string
----@param review_lines string[]
----@param list_opts table?
----@return diffs.GeneratedFileSelection?, diffs.DiffSpec?, string[]?, string?, integer?, diffs.GeneratedFileSelection[]
-local function first_renderable_review_file(review_spec, repo_root, review_lines, list_opts)
- local last_err
- local last_level
- local skipped = {}
- for _, selected in
- ipairs(lists.generated_files(review_lines, review_generated_list_opts(list_opts)))
- do
- if selected.skipped then
- skipped[#skipped + 1] = selected
- else
- local diff_spec, diff_lines, err, level =
- render_review_file_selection(review_spec, repo_root, selected)
- if diff_spec and diff_lines then
- return selected, diff_spec, diff_lines, nil, nil, skipped
- end
- skipped[#skipped + 1] = selected
- last_err = err
- last_level = level
- end
- end
-
- return nil,
- nil,
- nil,
- last_err or 'no renderable review file selected',
- last_level or vim.log.levels.INFO,
- skipped
-end
-
----@param state diffs.ReviewSplitState
-local function sync_review_quickfix(state)
- local qf = vim.fn.getqflist({ items = 0, idx = 0 })
- for index, item in ipairs(qf.items) do
- local selected = lists.selected_generated_file({ item = item })
- if selected and (selected.key or selected.file) == state.selected_key then
- local source = generated.source(selected.bufnr)
- if
- source
- and source.kind == 'review'
- and source.repo_root == state.repo_root
- and vim.deep_equal(source.review, state.review)
- then
- if qf.idx ~= index then
- vim.fn.setqflist({}, 'a', { idx = index })
- end
- return
- end
- end
- end
-end
-
----@param state diffs.ReviewSplitState
-local function clear_review_split_autocmds(state)
- for _, id in ipairs(state.autocmds or {}) do
- pcall(vim.api.nvim_del_autocmd, id)
- end
- state.autocmds = {}
-end
-
----@param state diffs.ReviewSplitState
-local function forget_review_split(state)
- clear_review_split_autocmds(state)
- review_split_states[state.left_buf] = nil
- review_split_states[state.right_buf] = nil
-end
-
----@param state diffs.ReviewSplitState
-local function setup_review_split_panes(state)
- for _, buf in ipairs({ state.left_buf, state.right_buf }) do
- if vim.api.nvim_buf_is_valid(buf) then
- vim.b[buf].diffs_review = { display = state.display, layout = 'split' }
- for _, key in ipairs({ ']f', ']q' }) do
- if not get_buffer_keymap(buf, 'n', key) then
- vim.keymap.set('n', key, '(diffs-review-next-file)', { buffer = buf, remap = true })
- end
- end
- for _, key in ipairs({ '[f', '[q' }) do
- if not get_buffer_keymap(buf, 'n', key) then
- vim.keymap.set('n', key, '(diffs-review-prev-file)', { buffer = buf, remap = true })
- end
- end
- if not get_buffer_keymap(buf, 'n', 'gO') then
- vim.keymap.set(
- 'n',
- 'gO',
- '(diffs-review-select-file)',
- { buffer = buf, remap = true }
- )
- end
- if not get_buffer_keymap(buf, 'n', 'gs') then
- vim.keymap.set(
- 'n',
- 'gs',
- '(diffs-review-toggle-layout)',
- { buffer = buf, remap = true }
- )
- end
- end
- end
-end
-
----@param state diffs.ReviewSplitState
-local function attach_review_split_autocmds(state)
- clear_review_split_autocmds(state)
- setup_review_split_panes(state)
- for _, buf in ipairs({ state.left_buf, state.right_buf }) do
- state.autocmds[#state.autocmds + 1] = vim.api.nvim_create_autocmd('BufWipeout', {
- group = review_split_group,
- buffer = buf,
- once = true,
- callback = function()
- local current = review_split_states[buf]
- if current then
- forget_review_split(current)
- end
- end,
- })
- end
-end
-
----@param state diffs.ReviewSplitState
----@param selected diffs.GeneratedFileSelection
----@param opts? { quiet?: boolean }
----@return boolean, boolean
-local function switch_review_split_file(state, selected, opts)
- opts = opts or {}
- local diff_spec, diff_lines, err, level =
- render_review_file_selection(state.review, state.repo_root, selected)
- if not diff_spec or not diff_lines then
- if not opts.quiet then
- notify(err or 'cannot render review split file', level or vim.log.levels.WARN)
- end
- return false, true
- end
-
- local left_win = first_window_for_buffer(state.left_buf)
- local right_win = first_window_for_buffer(state.right_buf)
- if not left_win or not right_win then
- return false, false
- end
-
- local opened, split_err = split.open({
- spec = diff_spec,
- repo_root = state.repo_root,
- filetype = filetype_for_path(state.repo_root, selected.file),
- diff_lines = diff_lines,
- quickfix = false,
- title = 'review: ' .. state.display,
- hunk_index = selected.hunk_index or 1,
- reuse_wins = { left = left_win, right = right_win },
- })
- if not opened then
- notify(split_err or 'cannot open review split', vim.log.levels.ERROR)
- return false, false
- end
-
- forget_review_split(state)
- state.left_buf = opened.left_buf
- state.right_buf = opened.right_buf
- state.left_win = opened.left_win
- state.right_win = opened.right_win
- state.selected_file = selected.file
- state.selected_key = selected.key or selected.file
- state.selected_diff_spec = diff_spec
- review_split_states[state.left_buf] = state
- review_split_states[state.right_buf] = state
- attach_review_split_autocmds(state)
- sync_review_quickfix(state)
- return true, false
-end
-
----@param state diffs.ReviewSplitState
----@return diffs.GeneratedFileSelection[]
-local function review_split_files(state)
- return lists.generated_files(state.review_lines, review_generated_list_opts(state.list_opts))
-end
-
----@param files diffs.GeneratedFileSelection[]
----@param state diffs.ReviewSplitState
----@return integer
-local function review_index_of_current(files, state)
- for i, selection in ipairs(files) do
- if (selection.key or selection.file) == state.selected_key then
- return i
- end
- end
- return 1
-end
-
----@param selection diffs.GeneratedFileSelection
----@param index integer
----@param count integer
----@return string
-local function review_file_message(selection, index, count)
- local label = selection.section_label
- local entry = (type(label) == 'string' and label ~= '')
- and ('[%s] %s'):format(label, selection.file)
- or selection.file
- return ('(%d of %d): %s'):format(index, count, entry)
-end
-
----@param selection diffs.GeneratedFileSelection
----@param index integer
----@param count integer
-local function announce_review_file(selection, index, count)
- vim.api.nvim_echo({ { '[diffs]: ' .. review_file_message(selection, index, count) } }, false, {})
-end
-
----@param skipped diffs.GeneratedFileSelection[]
----@return string?
-local function skipped_review_files_message(skipped)
- if #skipped == 0 then
- return nil
- end
- local paths = {}
- for _, selection in ipairs(skipped) do
- paths[#paths + 1] = selection.file
- end
- return ('review skipped %d file(s): %s'):format(#skipped, table.concat(paths, ', '))
-end
-
----@param skipped diffs.GeneratedFileSelection[]
-local function notify_skipped_review_files(skipped)
- local message = skipped_review_files_message(skipped)
- if message then
- notify(message, vim.log.levels.INFO)
- end
-end
-
----@param skipped diffs.GeneratedFileSelection[]
----@param selection diffs.GeneratedFileSelection
----@param index integer
----@param count integer
-local function notify_skipped_review_file(skipped, selection, index, count)
- local skipped_message = skipped_review_files_message(skipped)
- if not skipped_message then
- announce_review_file(selection, index, count)
- return
- end
- notify(
- skipped_message .. '\n[diffs]: ' .. review_file_message(selection, index, count),
- vim.log.levels.INFO
- )
-end
-
----@param state diffs.ReviewSplitState
----@param selection diffs.GeneratedFileSelection
----@param index integer
----@param count integer
----@return boolean
-local function goto_selection(state, selection, index, count)
- if switch_review_split_file(state, selection) then
- announce_review_file(selection, index, count)
- return true
- end
- return false
-end
-
----@param state diffs.ReviewSplitState
----@param delta integer
-local function step_review_split_file(state, delta)
- local files = review_split_files(state)
- local count = #files
- if count < 2 then
- return
- end
- local current = review_index_of_current(files, state)
- local skipped = {}
- for offset = 1, count - 1 do
- local target = ((current - 1 + delta * offset) % count) + 1
- local selected = files[target]
- if selected.skipped then
- skipped[#skipped + 1] = selected
- else
- local switched, unsupported = switch_review_split_file(state, selected, { quiet = true })
- if switched then
- notify_skipped_review_file(skipped, selected, target, count)
- return
- end
- if not unsupported then
- notify_skipped_review_files(skipped)
- return
- end
- skipped[#skipped + 1] = selected
- end
- end
- notify_skipped_review_files(skipped)
-end
-
----@param delta integer
-local function step_current_review_split(delta)
- local state = review_split_states[vim.api.nvim_get_current_buf()]
- if state then
- step_review_split_file(state, delta)
- end
-end
-
-function M.review_next_file()
- step_current_review_split(1)
-end
-
-function M.review_prev_file()
- step_current_review_split(-1)
-end
-
----@class diffs.ReviewFile
----@field path string
----@field key string
----@field section? string
----@field section_label? string
----@field added integer
----@field removed integer
----@field skipped boolean
-
----@param selection diffs.GeneratedFileSelection
----@return diffs.ReviewFile
-local function project_review_file(selection)
- return {
- path = selection.file,
- key = selection.key or selection.file,
- section = selection.section,
- section_label = selection.section_label,
- added = selection.added or 0,
- removed = selection.removed or 0,
- skipped = selection.skipped == true,
- }
-end
-
----@param bufnr integer?
----@return diffs.ReviewSplitState?
-local function resolve_review_split(bufnr)
- return review_split_states[bufnr or vim.api.nvim_get_current_buf()]
-end
-
----@param bufnr integer? Defaults to the current buffer.
----@return diffs.ReviewFile[]? files Nil when the buffer is not a review split.
-function M.review_files(bufnr)
- local state = resolve_review_split(bufnr)
- if not state then
- return nil
- end
- local files = {}
- for _, selection in ipairs(review_split_files(state)) do
- files[#files + 1] = project_review_file(selection)
- end
- return files
-end
-
----@param bufnr integer? Defaults to the current buffer.
----@return { index: integer, count: integer, file: diffs.ReviewFile }?
-function M.review_current(bufnr)
- local state = resolve_review_split(bufnr)
- if not state then
- return nil
- end
- local files = review_split_files(state)
- if #files == 0 then
- return nil
- end
- local index = review_index_of_current(files, state)
- return { index = index, count = #files, file = project_review_file(files[index]) }
-end
-
----@param target string A review file key (preferred) or path.
----@param bufnr integer? Defaults to the current buffer.
----@return boolean switched
-function M.review_goto(target, bufnr)
- local state = resolve_review_split(bufnr)
- if not state then
- return false
- end
- local files = review_split_files(state)
- for i, selection in ipairs(files) do
- if selection.key == target or selection.file == target then
- if (selection.key or selection.file) == state.selected_key then
- return true
- end
- if selection.skipped then
- notify_skipped_review_files({ selection })
- return false
- end
- return goto_selection(state, selection, i, #files)
- end
- end
- return false
-end
-
----@param item table?
----@return boolean handled
-function M.review_quickfix_jump(item)
- local selected = lists.selected_generated_file({ item = item })
- if not selected then
- return false
- end
- local source = generated.source(selected.bufnr)
- if not source or source.kind ~= 'review' then
- return false
- end
-
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local state = review_split_states[vim.api.nvim_win_get_buf(win)]
- if
- state
- and state.repo_root == source.repo_root
- and vim.deep_equal(state.review, source.review)
- then
- if M.review_goto(selected.key or selected.file, state.right_buf) then
- vim.api.nvim_set_current_win(state.right_win)
- end
- return true
- end
- end
- return false
-end
-
----@param file diffs.ReviewFile
----@return string
-local function review_file_label(file)
- local path = file.skipped and (file.path .. ' (skipped)') or file.path
- if file.section_label and file.section_label ~= '' then
- return ('[%s] %s'):format(file.section_label, path)
- end
- return path
-end
-
----@param files diffs.ReviewFile[]
----@return fun(file: diffs.ReviewFile): string
-local function review_file_formatter(files)
- local labels = {}
- local max_label, max_add, max_del = 0, 0, 0
- for _, file in ipairs(files) do
- local label = review_file_label(file)
- labels[file.key] = label
- max_label = math.max(max_label, #label)
- if file.added > 0 then
- max_add = math.max(max_add, #tostring(file.added) + 1)
- end
- if file.removed > 0 then
- max_del = math.max(max_del, #tostring(file.removed) + 1)
- end
- end
- return function(file)
- local label = labels[file.key] or review_file_label(file)
- local parts = { label .. string.rep(' ', max_label - #label) }
- if max_add > 0 then
- parts[#parts + 1] = file.added > 0 and string.format('%' .. max_add .. 's', '+' .. file.added)
- or string.rep(' ', max_add)
- end
- if max_del > 0 then
- parts[#parts + 1] = file.removed > 0
- and string.format('%' .. max_del .. 's', '-' .. file.removed)
- or string.rep(' ', max_del)
- end
- return (table.concat(parts, ' '):gsub('%s+$', ''))
- end
-end
-
----@param bufnr integer? Defaults to the current buffer.
-function M.select_review_file(bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
- local files = M.review_files(bufnr)
- if not files or #files == 0 then
- return
- end
- vim.ui.select(files, {
- prompt = 'Review file:',
- format_item = review_file_formatter(files),
- }, function(choice)
- if choice then
- M.review_goto(choice.key, bufnr)
- end
- end)
-end
-
-local function close_existing_review_splits()
- ---@type table
- local seen = {}
- for _, state in pairs(review_split_states) do
- if not seen[state] then
- seen[state] = true
- forget_review_split(state)
- if vim.api.nvim_buf_is_valid(state.left_buf) then
- split.close_pair(state.left_buf)
- elseif vim.api.nvim_buf_is_valid(state.right_buf) then
- split.close_pair(state.right_buf)
- end
- end
- end
-end
-
----@param spec diffs.ReviewSpec?
----@param opts? diffs.OpenReviewSplitOpts
----@return integer?
-open_review_split = function(spec, opts)
- opts = opts or {}
- local normalized, normalize_err = review.normalize(spec)
- if not normalized then
- notify(normalize_err, vim.log.levels.ERROR)
- return nil
- end
-
- local review_lines, render_err, list_opts = review.render(normalized, review_deps())
- if not review_lines then
- notify(render_err, vim.log.levels.ERROR)
- return nil
- end
- if #review_lines == 0 then
- notify('no diff against ' .. normalized.display, vim.log.levels.INFO)
- return nil
- end
-
- local review_spec = review.stored_review_spec(normalized)
- local first = opts.selection
- local diff_spec, diff_lines
- local skipped = {}
- if first then
- if first.skipped then
- notify_skipped_review_files({ first })
- return nil
- end
- local err, level
- diff_spec, diff_lines, err, level =
- render_review_file_selection(review_spec, normalized.repo_root, first)
- if not diff_spec or not diff_lines then
- notify(err or 'cannot render review split file', level or vim.log.levels.ERROR)
- return nil
- end
- else
- local err, level
- first, diff_spec, diff_lines, err, level, skipped =
- first_renderable_review_file(review_spec, normalized.repo_root, review_lines, list_opts)
- if not first or not diff_spec or not diff_lines then
- notify_skipped_review_files(skipped)
- if #skipped == 0 then
- notify(err or 'no review file selected', level or vim.log.levels.INFO)
- end
- return nil
- end
- end
-
- local restore_win = vim.api.nvim_get_current_win()
- close_existing_review_splits()
- if type(opts.replace_win) == 'number' then
- if not vim.api.nvim_win_is_valid(opts.replace_win) then
- notify('review split replacement window is no longer valid', vim.log.levels.WARN)
- return nil
- end
- vim.api.nvim_set_current_win(opts.replace_win)
- else
- restore_window(restore_win)
- end
-
- local opened, split_err = split.open({
- spec = diff_spec,
- repo_root = normalized.repo_root,
- filetype = filetype_for_path(normalized.repo_root, first.file),
- diff_lines = diff_lines,
- quickfix = false,
- title = 'review: ' .. normalized.display,
- hunk_index = first.hunk_index or 1,
- })
- if not opened then
- notify(split_err or 'cannot open review split', vim.log.levels.ERROR)
- return nil
- end
-
- ---@type diffs.ReviewSplitState
- local state = {
- left_buf = opened.left_buf,
- right_buf = opened.right_buf,
- left_win = opened.left_win,
- right_win = opened.right_win,
- review = review_spec,
- repo_root = normalized.repo_root,
- display = normalized.display,
- review_lines = review_lines,
- list_opts = list_opts,
- map_layout = normalize_review_map_layout(opts.map_layout),
- selected_file = first.file,
- selected_key = first.key or first.file,
- selected_diff_spec = diff_spec,
- autocmds = {},
- }
- review_split_states[opened.left_buf] = state
- review_split_states[opened.right_buf] = state
- attach_review_split_autocmds(state)
- sync_review_quickfix(state)
- split.move_pair_to_source(opened.right_buf, opts.source_position)
-
- local files = lists.generated_files(review_lines, review_generated_list_opts(list_opts))
- local index = review_index_of_current(files, state)
- notify_skipped_review_file(skipped, first, index, #files)
-
- dbg('opened review split %d/%d (%s)', opened.left_buf, opened.right_buf, normalized.display)
- return opened.left_buf
-end
-
----@param opts? diffs.ReviewSplitOpts
----@return integer?
-function M.review_split(opts)
- local restore_win = vim.api.nvim_get_current_win()
- local context, err, level = review_split_context(opts)
- if not context then
- restore_window(restore_win)
- notify(err or 'cannot open review split', level or vim.log.levels.WARN)
- return nil
- end
-
- local source, source_err = get_source_var(context.review_buf)
- if source_err or not source or source.kind ~= 'review' then
- restore_window(restore_win)
- notify(
- source_err and ('invalid diffs_source metadata: ' .. tostring(source_err))
- or 'selected file is not from a review buffer',
- vim.log.levels.WARN
- )
- return nil
- end
-
- local review_spec = vim.deepcopy(source.review)
- review_spec.repo = context.repo_root
- return open_review_split(review_spec, {
- selection = context.selected,
- source_position = context.source_position,
- replace_win = context.replace_win,
- map_layout = context.map_layout,
- })
-end
-
----@param state diffs.ReviewSplitState
----@return integer?
-local function review_split_keep_window(state)
- local current_win = vim.api.nvim_get_current_win()
- local current_buf = vim.api.nvim_get_current_buf()
- if
- (current_buf == state.left_buf or current_buf == state.right_buf)
- and vim.api.nvim_win_is_valid(current_win)
- then
- return current_win
- end
- for _, win in ipairs({ state.left_win, state.right_win }) do
- if vim.api.nvim_win_is_valid(win) then
- local buf = vim.api.nvim_win_get_buf(win)
- if buf == state.left_buf or buf == state.right_buf then
- return win
- end
- end
- end
- return first_window_for_buffer(state.left_buf) or first_window_for_buffer(state.right_buf)
-end
-
----@param bufnr integer
----@param selected_file string
----@param selected_key string?
----@param position diffs.SourcePosition?
----@return boolean
-local function goto_review_map_selection(bufnr, selected_file, selected_key, position)
- local win = first_window_for_buffer(bufnr)
- if not win then
- return false
- end
- local matched_line = position
- and hunk_model.line_at_source(
- lists.generated_hunks(bufnr),
- { file = selected_file, key = selected_key },
- position
- )
- for _, item in ipairs(vim.fn.getqflist({ items = 0 }).items) do
- local data = item.user_data and item.user_data.diffs
- if item.bufnr == bufnr and type(data) == 'table' then
- local matches = selected_key and data.key == selected_key
- or (not selected_key and data.file == selected_file)
- if matches then
- local lnum = (matched_line and matched_line.lnum) or item.lnum or 1
- vim.api.nvim_set_current_win(win)
- vim.api.nvim_win_set_cursor(win, { math.max(1, lnum), 0 })
- vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr })
- return true
- end
- end
- end
- return false
-end
-
----@param state diffs.ReviewSplitState
----@param layout diffs.ReviewMapLayout
----@return integer?
-local function open_review_map_from_split(state, layout)
- local keep_win = review_split_keep_window(state)
- if not keep_win then
- notify('review split window is no longer visible', vim.log.levels.WARN)
- return nil
- end
- local source_buf = vim.api.nvim_win_get_buf(keep_win)
- local selected_file = state.selected_file
- local selected_key = state.selected_key
- local position = split.displayed_position(source_buf, vim.api.nvim_win_get_cursor(keep_win)[1])
- local review_spec = vim.deepcopy(state.review)
- review_spec.repo = state.repo_root
- forget_review_split(state)
- local split_buffers = split.close_pair_into_window(source_buf, keep_win)
- if not split_buffers then
- review_split_states[state.left_buf] = state
- review_split_states[state.right_buf] = state
- attach_review_split_autocmds(state)
- notify('cannot close review split before opening review map', vim.log.levels.WARN)
- return nil
- end
- local bufnr = M.review(review_spec, {
- rail_style = rail_style_for_layout(layout),
- review_layout = layout,
- replace_win = keep_win,
- })
- split.delete_pair_buffers(split_buffers)
- if bufnr then
- goto_review_map_selection(bufnr, selected_file, selected_key, position)
- end
- return bufnr
-end
-
----@class diffs.ReviewToggleLayoutOpts
----@field bufnr? integer
-
----@param opts? diffs.ReviewToggleLayoutOpts
----@return integer?
-function M.review_toggle_layout(opts)
- opts = opts or {}
- local bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
- local state = resolve_review_split(bufnr)
- if state then
- return open_review_map_from_split(state, state.map_layout)
- end
- return M.review_split({ bufnr = bufnr })
-end
-
----@param args? string
----@param vertical? boolean
----@param opts? { warn_vertical_split?: boolean }
-function M.diff(args, vertical, opts)
- opts = opts or {}
- local bufnr = vim.api.nvim_get_current_buf()
- local filepath = vim.api.nvim_buf_get_name(bufnr)
-
- if filepath == '' then
- notify('cannot diff unnamed buffer', vim.log.levels.ERROR)
- return
- end
-
- local rel_path = git.get_relative_path(filepath)
- if not rel_path then
- notify('not in a git repository', vim.log.levels.ERROR)
- return
- end
-
- local parsed, parse_err = diff_parser.parse(args, {
- path = rel_path,
- current = diffspec.worktree(),
- default_layout = runtime.get_view_config().default_layout,
- })
- if not parsed then
- notify(parse_err, vim.log.levels.ERROR)
- return
- end
-
- if parsed.layout == 'split' and opts.warn_vertical_split then
- warn_vertical_split_ignored()
- end
-
- local rail_style = rail_style_for_layout(parsed.layout)
- local diff_spec = parsed.spec
- local diff_label = diff_buffer_label(diff_spec)
- local diff_path = diff_spec.scope.path
- local repo_root = git.get_repo_root(filepath)
- if not repo_root then
- notify('not in a git repository', vim.log.levels.ERROR)
- return
- end
-
- local diff_filepath = repo_root .. '/' .. diff_path
-
- if diff_spec.left.kind == diffspec.endpoint_kind.stage and not git.is_unmerged(diff_filepath) then
- notify(diff_path .. ' is not in a merge conflict', vim.log.levels.ERROR)
- return
- end
-
- if
- diff_spec.left.kind == diffspec.endpoint_kind.index
- and diff_spec.right.kind == diffspec.endpoint_kind.worktree
- and diff_spec.scope.kind == diffspec.scope_kind.file
- and diff_path == rel_path
- and git.is_unmerged(filepath)
- then
- if parsed.layout == 'split' then
- notify('split diff does not support unmerged files yet', vim.log.levels.ERROR)
- return
- end
- M.diff_file(filepath, {
- vertical = vertical,
- unmerged = true,
- rail_style = rail_style,
- })
- return
- end
-
- -- Only the current buffer's content stands in for the worktree side; an
- -- explicit-path object targets a different file that must be read from disk.
- local worktree_lines = diff_path == rel_path and content.from_buffer(bufnr) or nil
- local diff_lines, render_err = render.file(diff_spec, repo_root, {
- worktree_lines = worktree_lines,
- })
- if not diff_lines then
- notify(render_err or 'unknown error', vim.log.levels.ERROR)
- return
- end
-
- if #diff_lines == 0 then
- notify('no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO)
- return
- end
-
- if parsed.layout == 'split' then
- local opened, split_err = split.open({
- spec = diff_spec,
- repo_root = repo_root,
- filetype = vim.api.nvim_get_option_value('filetype', { buf = bufnr }),
- worktree_lines = worktree_lines,
- worktree_buf = worktree_lines and bufnr or nil,
- diff_lines = diff_lines,
- })
- if not opened then
- notify(split_err or 'cannot open split diff', vim.log.levels.ERROR)
- end
- return
- end
-
- local diff_buf = create_generated_diff_buffer({
- name = 'diffs://' .. diff_label .. ':' .. diff_path,
- lines = diff_lines,
- repo_root = repo_root,
- diff_spec = diff_spec,
- source = generated.file_source(repo_root, diff_spec, worktree_lines and bufnr or nil),
- rail_style = rail_style,
- })
- show_generated_diff_buffer(diff_buf, vertical)
- lists.set_for_unified_buffer(diff_buf, diff_lines, {
- title = 'diff: ' .. diffspec.label(diff_spec),
- })
- attach_generated_diff_buffer(diff_buf)
-
- if difftastic.available() then
- local abs = repo_root and (repo_root .. '/' .. diff_path) or diff_path
- local old_lines = render.read_endpoint(diff_spec.left, abs, { empty_on_missing = true })
- local new_lines = render.read_endpoint(diff_spec.right, abs, {
- worktree_lines = worktree_lines,
- empty_on_missing = true,
- })
- apply_difft_unified(diff_buf, diff_lines, diff_spec, old_lines, new_lines, diff_path)
- end
-
- dbg('opened diff buffer %d for %s (%s)', diff_buf, diff_path, diffspec.label(diff_spec))
-end
-
----@class diffs.DiffFileOpts
----@field vertical? boolean
----@field staged? boolean
----@field untracked? boolean
----@field unmerged? boolean
----@field old_filepath? string
----@field hunk_position? { hunk_header: string, offset: integer }
----@field rail_style? diffs.RailStyle
-
----@param filepath string
----@param opts? diffs.DiffFileOpts
-function M.diff_file(filepath, opts)
- opts = opts or {}
-
- local rel_path = git.get_relative_path(filepath)
- if not rel_path then
- notify('not in a git repository', vim.log.levels.ERROR)
- return
- end
-
- local repo_root = git.get_repo_root(filepath)
- if not repo_root then
- notify('not in a git repository', vim.log.levels.ERROR)
- return
- end
-
- local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path
-
- local old_lines, new_lines, err, diff_lines
- local diff_label
- local diff_spec
-
- if opts.unmerged then
- old_lines = git.get_file_content(':2', filepath)
- if not old_lines then
- old_lines = {}
- end
- new_lines = git.get_file_content(':3', filepath)
- if not new_lines then
- new_lines = {}
- end
- diff_label = 'unmerged'
- elseif opts.untracked then
- diff_spec = diffspec.index_to_worktree(rel_path)
- diff_label = 'untracked'
- diff_lines, err = render.file(diff_spec, repo_root)
- elseif opts.staged then
- diff_label = 'staged'
- if old_rel_path == rel_path then
- diff_spec = diffspec.head_to_index(rel_path)
- diff_lines, err = render.file(diff_spec, repo_root)
- else
- old_lines = git.get_file_content('HEAD', opts.old_filepath or filepath) or {}
- new_lines = git.get_index_content(filepath) or {}
- end
- else
- diff_label = 'unstaged'
- if old_rel_path == rel_path then
- diff_spec = diffspec.index_to_worktree(rel_path)
- diff_lines, err = render.file(diff_spec, repo_root)
- else
- old_lines = git.get_index_content(opts.old_filepath or filepath)
- if not old_lines then
- old_lines = git.get_file_content('HEAD', opts.old_filepath or filepath) or {}
- end
- new_lines = git.get_working_content(filepath) or {}
- end
- end
-
- if not diff_lines then
- if err then
- notify(err, vim.log.levels.ERROR)
- return
- end
- diff_lines = render.unified_lines(old_lines or {}, new_lines or {}, old_rel_path, rel_path)
- end
-
- if #diff_lines == 0 then
- if diff_spec then
- notify('no changes for ' .. diffspec.label(diff_spec), vim.log.levels.INFO)
- elseif opts.unmerged then
- notify('no changes for unmerged file:' .. rel_path, vim.log.levels.INFO)
- else
- notify(
- 'no text changes for '
- .. file_pair_label(diff_label, rel_path, old_rel_path)
- .. '; generated hunk actions are unavailable',
- vim.log.levels.INFO
- )
- end
- return
- end
-
- local source
- if diff_spec then
- source = generated.file_source(repo_root, diff_spec)
- elseif opts.unmerged then
- source = generated.unmerged_source(repo_root, rel_path, filepath)
- else
- local edge = opts.staged and 'staged' or 'unstaged'
- source = generated.file_pair_source(repo_root, edge, rel_path, old_rel_path)
- end
-
- local diff_buf = create_generated_diff_buffer({
- name = 'diffs://' .. diff_label .. ':' .. rel_path,
- lines = diff_lines,
- repo_root = repo_root,
- diff_spec = diff_spec,
- source = source,
- rail_style = opts.rail_style,
- })
- show_generated_diff_buffer(diff_buf, opts.vertical)
-
- if opts.hunk_position then
- local target_line = M.find_hunk_line(diff_lines, opts.hunk_position)
- if target_line then
- vim.api.nvim_win_set_cursor(0, { target_line, 0 })
- dbg('jumped to line %d for hunk', target_line)
- end
- end
-
- lists.set_for_unified_buffer(diff_buf, diff_lines, {
- title = 'diff: ' .. diff_label .. ':' .. rel_path,
- diff_spec = diff_spec,
- })
-
- attach_generated_diff_buffer(diff_buf)
-
- if difftastic.available() and diff_label ~= 'unmerged' then
- ---@type diffs.ContentLines|string[]|nil
- local dold = old_lines
- ---@type diffs.ContentLines|string[]|nil
- local dnew = new_lines
- if (not dold or not dnew) and diff_spec then
- local abs = repo_root .. '/' .. rel_path
- dold = render.read_endpoint(diff_spec.left, abs, { empty_on_missing = true })
- dnew = render.read_endpoint(diff_spec.right, abs, { empty_on_missing = true })
- end
- apply_difft_unified(diff_buf, diff_lines, diff_spec, dold, dnew, rel_path)
- end
-
- if diff_label == 'unmerged' then
- local conflict_config = runtime.get_conflict_config()
- if conflict_config then
- require('diffs.merge').attach(diff_buf)
- end
- end
-
- dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label)
-end
-
----@class diffs.DiffSectionOpts
----@field vertical? boolean
----@field staged? boolean
-
----@param repo_root string
----@param opts? diffs.DiffSectionOpts
-function M.diff_section(repo_root, opts)
- opts = opts or {}
-
- local cmd = {
- 'git',
- '-C',
- repo_root,
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- }
- vim.list_extend(cmd, diffopt.git_flags())
- if opts.staged then
- table.insert(cmd, '--cached')
- end
-
- local result = vim.fn.systemlist(cmd)
- if vim.v.shell_error ~= 0 then
- notify('git diff failed', vim.log.levels.ERROR)
- return
- end
-
- result = replace_combined_diffs(result, repo_root)
-
- local diff_label = opts.staged and 'staged' or 'unstaged'
- if #result == 0 then
- notify('no changes in ' .. diff_label .. ' section', vim.log.levels.INFO)
- return
- end
-
- local diff_buf = create_generated_diff_buffer({
- name = 'diffs://' .. diff_label .. ':all',
- lines = result,
- repo_root = repo_root,
- source = generated.section_source(repo_root, diff_label),
- })
- show_generated_diff_buffer(diff_buf, opts.vertical)
- lists.set_for_unified_buffer(diff_buf, result, {
- title = 'diff: ' .. diff_label .. ':all',
- })
- attach_generated_diff_buffer(diff_buf)
- dbg('opened section diff buffer %d (%s)', diff_buf, diff_label)
-end
-
----@param bufnr integer
----@param opts? { quickfix?: boolean }
-function M.read_buffer(bufnr, opts)
- opts = opts or {}
- local name = vim.api.nvim_buf_get_name(bufnr)
- if not name:match('^diffs://') then
- return
- end
-
- generated.restore(bufnr)
-
- local source, source_err = get_source_var(bufnr)
- if source_err then
- notify('invalid diffs_source metadata: ' .. tostring(source_err), vim.log.levels.WARN)
- return
- end
-
- if source and source.kind == 'split_endpoint' then
- local ok, err = split.read_buffer(bufnr, source, {
- quickfix = opts.quickfix,
- })
- if not ok then
- notify(err or 'cannot reload split diffs:// buffer', vim.log.levels.WARN)
- end
- return
- end
-
- if not source then
- notify('cannot reload diffs:// buffer without diffs_source metadata', vim.log.levels.WARN)
- return
- end
-
- local diff_lines, stored_spec, render_err, list_opts, old_lines, new_lines = render_source(source)
- if not diff_lines then
- notify(render_err or 'cannot reload diffs:// buffer', vim.log.levels.WARN)
- return
- end
- local debug_label = render_err
-
- local is_review = source.kind == 'review'
- replace_generated_diff_buffer_lines(bufnr, diff_lines, stored_spec)
- lists.set_for_unified_buffer(bufnr, diff_lines, {
- title = 'diff: ' .. (debug_label or name:gsub('^diffs://', '')),
- diff_spec = stored_spec,
- metadata_for_line = list_opts and list_opts.metadata_for_line,
- sections = list_opts and list_opts.sections,
- store_hunks = list_opts and list_opts.store_hunks,
- quickfix = opts.quickfix ~= false and (is_review or nil),
- })
- M.setup_diff_buf(bufnr)
-
- local review_marker = generated.get_var(bufnr, 'diffs_review')
- if type(review_marker) == 'table' and review_marker.layout ~= 'split' then
- setup_review_map_buffer(
- bufnr,
- review_marker.display,
- normalize_review_map_layout(review_marker.layout)
- )
- end
-
- dbg('reloaded diff buffer %d (%s)', bufnr, debug_label)
-
- runtime.attach(bufnr)
- local relpath = stored_spec and stored_spec.scope.path or source.right_name or source.path
- if relpath then
- apply_difft_unified(bufnr, diff_lines, stored_spec, old_lines, new_lines, relpath)
- end
-end
-
---- Re-render every visible diffs:// buffer in place, preserving each window's
---- view. Used after a global change such as 'diffopt', so the new setting is
---- reflected consistently across all open diff surfaces.
-function M.refresh_visible()
- ---@type { win: integer, view: table }[]
- local views = {}
- for _, win in ipairs(vim.api.nvim_list_wins()) do
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_buf_get_name(buf):match('^diffs://') then
- views[#views + 1] = { win = win, view = vim.api.nvim_win_call(win, vim.fn.winsaveview) }
- end
- end
-
- ---@type table
- local done = {}
- for _, entry in ipairs(views) do
- local buf = vim.api.nvim_win_is_valid(entry.win) and vim.api.nvim_win_get_buf(entry.win)
- if buf and not done[buf] then
- done[buf] = true
- local ok, peer = pcall(vim.api.nvim_buf_get_var, buf, 'diffs_split_peer')
- if ok and type(peer) == 'number' then
- done[peer] = true
- end
- M.read_buffer(buf)
- end
- end
-
- for _, entry in ipairs(views) do
- if vim.api.nvim_win_is_valid(entry.win) then
- vim.api.nvim_win_call(entry.win, function()
- vim.fn.winrestview(entry.view)
- end)
- end
- end
-end
-
---- Re-render a single diffs:// buffer in place, preserving its window view.
----@param bufnr? integer
-function M.refresh(bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
- if not vim.api.nvim_buf_get_name(bufnr):match('^diffs://') then
- return
- end
- local win = first_window_for_buffer(bufnr)
- local view = win and vim.api.nvim_win_call(win, vim.fn.winsaveview) or nil
- M.read_buffer(bufnr)
- if win and view and vim.api.nvim_win_is_valid(win) then
- vim.api.nvim_win_call(win, function()
- vim.fn.winrestview(view)
- end)
- end
-end
-
---- React to a runtime change of the global 'diffopt'. Generated diffs:// buffers
---- regenerate (their content honors the whitespace/algorithm flags), attached
---- repaint surfaces are invalidated so whitespace-only lines are re-classified,
---- and native &diff windows are refreshed so they pick up the new options
---- immediately. Deferred so buffer rewrites do not run inside the OptionSet
---- callback, then a redraw flushes the new highlighting.
-function M.on_diffopt_changed()
- vim.schedule(function()
- M.refresh_visible()
- runtime.invalidate_attached()
- pcall(vim.cmd.diffupdate)
- vim.cmd.redraw({ bang = true })
- end)
-end
-
-M.complete_diff_command = complete_diff_command
-
-M._test = {
- complete_diff = complete_diff_command,
- complete_diff_args = complete_diff_args,
- complete_diff_object = complete_diff_object,
- complete_review = review.complete,
- create_generated_diff_buffer = create_generated_diff_buffer,
- diff_buffer_label = diff_buffer_label,
- replace_generated_diff_buffer_lines = replace_generated_diff_buffer_lines,
- review_split_state = function(bufnr)
- return review_split_states[bufnr]
- end,
- normalize_review = review.normalize,
-}
-
-return M
diff --git a/lua/diffs/config.lua b/lua/diffs/config.lua
index 068b48b0..c7e0f1b3 100644
--- a/lua/diffs/config.lua
+++ b/lua/diffs/config.lua
@@ -12,7 +12,6 @@ local integration_metadata = require('diffs.integrations')
---@field max_lines integer
---@class diffs.ViewConfig
----@field default_layout "unified"|"stacked"|"split"
---@field prefix boolean
---@class diffs.Highlights
@@ -23,9 +22,6 @@ local integration_metadata = require('diffs.integrations')
---@field vim diffs.VimConfig|false
---@field intra diffs.IntraConfig
----@class diffs.DifftasticConfig
----@field args? string[]
-
---@class diffs.IntegrationsConfig
---@field fugitive? boolean
---@field neogit? boolean
@@ -33,20 +29,17 @@ local integration_metadata = require('diffs.integrations')
---@field gitsigns? boolean
---@field committia? boolean
---@field telescope? boolean
----@field difftastic? boolean|diffs.DifftasticConfig
---@class diffs.Config
---@field debug boolean|string
---@field view diffs.ViewConfig
---@field highlights diffs.Highlights
---@field integrations diffs.IntegrationsConfig
----@field conflict boolean
---@type diffs.Config
local DEFAULTS = {
debug = false,
view = {
- default_layout = 'unified',
prefix = true,
},
highlights = {
@@ -63,7 +56,6 @@ local DEFAULTS = {
},
},
integrations = {},
- conflict = true,
}
---@param opts? table
@@ -86,15 +78,6 @@ function M.validate(opts)
for _, key in ipairs(integration_metadata.keys()) do
vim.validate('integrations.' .. key, integrations[key], 'boolean', true)
end
- vim.validate('integrations.difftastic', integrations.difftastic, function(v)
- return v == nil or type(v) == 'boolean' or type(v) == 'table'
- end, 'boolean or table')
- if type(integrations.difftastic) == 'table' then
- vim.validate('integrations.difftastic.args', integrations.difftastic.args, 'table', true)
- for i, arg in ipairs(integrations.difftastic.args or {}) do
- vim.validate('integrations.difftastic.args[' .. i .. ']', arg, 'string')
- end
- end
vim.validate('highlights', opts.highlights, 'table', true)
if opts.highlights then
@@ -117,8 +100,6 @@ function M.validate(opts)
end
end
- vim.validate('conflict', opts.conflict, 'boolean', true)
-
if
opts.highlights
and opts.highlights.blend_alpha
diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua
deleted file mode 100644
index eb0b36f1..00000000
--- a/lua/diffs/conflict.lua
+++ /dev/null
@@ -1,416 +0,0 @@
----@class diffs.ConflictRegion
----@field marker_open integer?
----@field marker_ours integer
----@field ours_start integer
----@field ours_end integer
----@field marker_base integer?
----@field base_start integer?
----@field base_end integer?
----@field marker_sep integer
----@field theirs_start integer
----@field theirs_end integer
----@field marker_theirs integer
-
-local M = {}
-
-local notify = require('diffs.log').notify
-
-local ns = vim.api.nvim_create_namespace('diffs-conflict')
-local CONFLICT_PRIORITY = 200
-
----@type table
-local attached_buffers = {}
-
----@param event string
----@param bufnr integer
-local function emit(event, bufnr)
- vim.api.nvim_exec_autocmds('User', { pattern = event, data = { bufnr = bufnr } })
-end
-
----@param line string
----@param char string
----@return boolean
-local function is_marker(line, char)
- return line:sub(1, 7) == string.rep(char, 7)
-end
-
----@param lines string[]
----@return diffs.ConflictRegion[]
-function M.parse(lines)
- local regions = {}
- local state = 'idle'
- ---@type table?
- local current = nil
-
- for i, line in ipairs(lines) do
- local idx = i - 1
-
- if state == 'idle' then
- if is_marker(line, '<') then
- local following = lines[i + 1]
- if following and is_marker(following, '+') then
- current = { marker_open = idx }
- state = 'in_snapshot_open'
- else
- current = { marker_ours = idx, ours_start = idx + 1 }
- state = 'in_ours'
- end
- end
- elseif state == 'in_ours' then
- if line:match('^|||||||') then
- current.ours_end = idx
- current.marker_base = idx
- current.base_start = idx + 1
- state = 'in_base'
- elseif line:match('^=======') then
- current.ours_end = idx
- current.marker_sep = idx
- current.theirs_start = idx + 1
- state = 'in_theirs'
- elseif line:match('^<<<<<<<') then
- current = { marker_ours = idx, ours_start = idx + 1 }
- elseif line:match('^>>>>>>>') then
- current = nil
- state = 'idle'
- end
- elseif state == 'in_base' then
- if line:match('^=======') then
- current.base_end = idx
- current.marker_sep = idx
- current.theirs_start = idx + 1
- state = 'in_theirs'
- elseif line:match('^<<<<<<<') then
- current = { marker_ours = idx, ours_start = idx + 1 }
- state = 'in_ours'
- elseif line:match('^>>>>>>>') then
- current = nil
- state = 'idle'
- end
- elseif state == 'in_theirs' then
- if line:match('^>>>>>>>') then
- current.theirs_end = idx
- current.marker_theirs = idx
- table.insert(regions, current)
- current = nil
- state = 'idle'
- elseif line:match('^<<<<<<<') then
- current = { marker_ours = idx, ours_start = idx + 1 }
- state = 'in_ours'
- end
- elseif state == 'in_snapshot_open' then
- current.marker_ours = idx
- current.ours_start = idx + 1
- state = 'in_snapshot_ours'
- elseif state == 'in_snapshot_ours' then
- if is_marker(line, '-') then
- current.ours_end = idx
- current.marker_base = idx
- current.base_start = idx + 1
- state = 'in_snapshot_base'
- elseif is_marker(line, '%') or is_marker(line, '+') or is_marker(line, '>') then
- current = nil
- state = 'idle'
- end
- elseif state == 'in_snapshot_base' then
- if is_marker(line, '+') then
- current.base_end = idx
- current.marker_sep = idx
- current.theirs_start = idx + 1
- state = 'in_snapshot_theirs'
- elseif is_marker(line, '%') or is_marker(line, '-') or is_marker(line, '>') then
- current = nil
- state = 'idle'
- end
- elseif state == 'in_snapshot_theirs' then
- if is_marker(line, '>') then
- current.theirs_end = idx
- current.marker_theirs = idx
- table.insert(regions, current)
- current = nil
- state = 'idle'
- elseif is_marker(line, '%') or is_marker(line, '+') or is_marker(line, '-') then
- current = nil
- state = 'idle'
- end
- end
- end
-
- return regions
-end
-
----@param region diffs.ConflictRegion
----@return integer
-local function region_start(region)
- return region.marker_open or region.marker_ours
-end
-
----@param bufnr integer
----@return diffs.ConflictRegion[]
-local function parse_buffer(bufnr)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- return M.parse(lines)
-end
-
----@param bufnr integer
----@param row integer
----@param hl_group string
-local function mark_line(bufnr, row, hl_group)
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, 0, {
- end_row = row + 1,
- hl_group = hl_group,
- hl_eol = true,
- priority = CONFLICT_PRIORITY,
- })
-end
-
----@param bufnr integer
----@param start_row integer
----@param end_row integer
----@param hl_group string
----@param number_hl_group string
-local function mark_section(bufnr, start_row, end_row, hl_group, number_hl_group)
- for row = start_row, end_row - 1 do
- mark_line(bufnr, row, hl_group)
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, row, 0, {
- number_hl_group = number_hl_group,
- priority = CONFLICT_PRIORITY,
- })
- end
-end
-
----@param bufnr integer
----@param regions diffs.ConflictRegion[]
-local function apply_highlights(bufnr, regions)
- vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
-
- for _, region in ipairs(regions) do
- if region.marker_open then
- mark_line(bufnr, region.marker_open, 'DiffsConflictMarker')
- end
- mark_line(bufnr, region.marker_ours, 'DiffsConflictMarker')
- mark_section(
- bufnr,
- region.ours_start,
- region.ours_end,
- 'DiffsConflictOurs',
- 'DiffsConflictOursNr'
- )
-
- if region.marker_base then
- mark_line(bufnr, region.marker_base, 'DiffsConflictMarker')
- mark_section(
- bufnr,
- region.base_start,
- region.base_end,
- 'DiffsConflictBase',
- 'DiffsConflictBaseNr'
- )
- end
-
- mark_line(bufnr, region.marker_sep, 'DiffsConflictMarker')
- mark_section(
- bufnr,
- region.theirs_start,
- region.theirs_end,
- 'DiffsConflictTheirs',
- 'DiffsConflictTheirsNr'
- )
- mark_line(bufnr, region.marker_theirs, 'DiffsConflictMarker')
- end
-end
-
----@param cursor_line integer
----@param regions diffs.ConflictRegion[]
----@return diffs.ConflictRegion?
-local function find_conflict_at_cursor(cursor_line, regions)
- for _, region in ipairs(regions) do
- if cursor_line >= region_start(region) and cursor_line <= region.marker_theirs then
- return region
- end
- end
- return nil
-end
-
----@param bufnr integer
----@param region diffs.ConflictRegion
----@param replacement string[]
-function M.replace_region(bufnr, region, replacement)
- vim.api.nvim_buf_set_lines(
- bufnr,
- region_start(region),
- region.marker_theirs + 1,
- false,
- replacement
- )
-end
-
----@param bufnr integer
-function M.refresh(bufnr)
- local had_conflicts = #vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, { limit = 1 }) > 0
- local regions = parse_buffer(bufnr)
- if #regions == 0 then
- vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
- if had_conflicts then
- emit('DiffsConflictResolved', bufnr)
- end
- return
- end
- apply_highlights(bufnr, regions)
- if not had_conflicts then
- emit('DiffsConflictDetected', bufnr)
- end
-end
-
----@param bufnr integer
----@param region diffs.ConflictRegion
----@param side 'ours'|'theirs'|'both'|'none'
----@return string[]
-function M.replacement_lines(bufnr, region, side)
- if side == 'none' then
- return {}
- end
- if side == 'theirs' then
- return vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false)
- end
- local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false)
- if side == 'both' then
- vim.list_extend(
- lines,
- vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false)
- )
- end
- return lines
-end
-
----@param bufnr integer
----@param side 'ours'|'theirs'|'both'|'none'
-local function resolve(bufnr, side)
- if not vim.api.nvim_get_option_value('modifiable', { buf = bufnr }) then
- notify('buffer is not modifiable', vim.log.levels.WARN)
- return
- end
- local regions = parse_buffer(bufnr)
- local region = find_conflict_at_cursor(vim.api.nvim_win_get_cursor(0)[1] - 1, regions)
- if not region then
- return
- end
- M.replace_region(bufnr, region, M.replacement_lines(bufnr, region, side))
- M.refresh(bufnr)
-end
-
----@param bufnr integer
-function M.resolve_ours(bufnr)
- resolve(bufnr, 'ours')
-end
-
----@param bufnr integer
-function M.resolve_theirs(bufnr)
- resolve(bufnr, 'theirs')
-end
-
----@param bufnr integer
-function M.resolve_both(bufnr)
- resolve(bufnr, 'both')
-end
-
----@param bufnr integer
-function M.resolve_none(bufnr)
- resolve(bufnr, 'none')
-end
-
----@param bufnr integer
----@param forward boolean
-local function goto_conflict(bufnr, forward)
- local regions = parse_buffer(bufnr)
- if #regions == 0 then
- return
- end
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
- if forward then
- for _, region in ipairs(regions) do
- if region_start(region) > cursor_line then
- vim.api.nvim_win_set_cursor(0, { region_start(region) + 1, 0 })
- return
- end
- end
- notify('wrapped to first conflict', vim.log.levels.INFO)
- vim.api.nvim_win_set_cursor(0, { region_start(regions[1]) + 1, 0 })
- else
- for i = #regions, 1, -1 do
- if region_start(regions[i]) < cursor_line then
- vim.api.nvim_win_set_cursor(0, { region_start(regions[i]) + 1, 0 })
- return
- end
- end
- notify('wrapped to last conflict', vim.log.levels.INFO)
- vim.api.nvim_win_set_cursor(0, { region_start(regions[#regions]) + 1, 0 })
- end
-end
-
----@param bufnr integer
-function M.goto_next(bufnr)
- goto_conflict(bufnr, true)
-end
-
----@param bufnr integer
-function M.goto_prev(bufnr)
- goto_conflict(bufnr, false)
-end
-
----@param bufnr integer
-function M.detach(bufnr)
- local attached = attached_buffers[bufnr]
- vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
- attached_buffers[bufnr] = nil
-
- if attached then
- emit('DiffsConflictDetached', bufnr)
- end
-end
-
----@param bufnr integer
-function M.attach(bufnr)
- if attached_buffers[bufnr] then
- return
- end
-
- local buftype = vim.api.nvim_get_option_value('buftype', { buf = bufnr })
- if buftype ~= '' then
- return
- end
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- local regions = M.parse(lines)
- if #regions == 0 then
- return
- end
-
- attached_buffers[bufnr] = true
-
- apply_highlights(bufnr, regions)
-
- vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
- buffer = bufnr,
- callback = function()
- if not attached_buffers[bufnr] then
- return true
- end
- M.refresh(bufnr)
- end,
- })
-
- vim.api.nvim_create_autocmd('BufWipeout', {
- buffer = bufnr,
- callback = function()
- attached_buffers[bufnr] = nil
- end,
- })
- emit('DiffsConflictDetected', bufnr)
-end
-
----@return integer
-function M.get_namespace()
- return ns
-end
-
-return M
diff --git a/lua/diffs/content.lua b/lua/diffs/content.lua
deleted file mode 100644
index e2e56c1a..00000000
--- a/lua/diffs/content.lua
+++ /dev/null
@@ -1,86 +0,0 @@
-local M = {}
-
----@class diffs.ContentLines: string[]
----@field _diffs_has_final_newline? boolean
-
----@class diffs.ContentFromRawOpts
----@field empty_is_empty? boolean
-
----@param lines string[]
----@param has_final_newline boolean?
----@return diffs.ContentLines
-local function content_lines(lines, has_final_newline)
- local result = {}
- for i, line in ipairs(lines) do
- result[i] = line
- end
- if has_final_newline then
- result._diffs_has_final_newline = true
- end
- return result
-end
-
----@param lines diffs.ContentLines|string[]?
----@return boolean
-local function has_final_newline(lines)
- return type(lines) == 'table' and lines._diffs_has_final_newline == true
-end
-
----@param lines string[]
----@param opts? diffs.ContentFromRawOpts
----@return diffs.ContentLines
-function M.from_raw_lines(lines, opts)
- opts = opts or {}
- local result = {}
- for i, line in ipairs(lines) do
- result[i] = line
- end
-
- if opts.empty_is_empty and #result == 1 and result[1] == '' then
- result = {}
- end
-
- local ends_with_final_newline = #result > 0 and result[#result] == ''
- if ends_with_final_newline then
- result[#result] = nil
- end
-
- return content_lines(result, ends_with_final_newline)
-end
-
----@param bufnr integer
----@param lines string[]
----@return boolean
-local function buffer_has_final_newline(bufnr, lines)
- if not vim.api.nvim_get_option_value('endofline', { buf = bufnr }) then
- return false
- end
-
- if #lines == 1 and lines[1] == '' then
- local byte_count = vim.api.nvim_buf_call(bufnr, function()
- return vim.fn.wordcount().bytes
- end)
- return byte_count > 0
- end
-
- return true
-end
-
----@param bufnr integer
----@return diffs.ContentLines
-function M.from_buffer(bufnr)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- return content_lines(lines, buffer_has_final_newline(bufnr, lines))
-end
-
----@param lines diffs.ContentLines|string[]
----@return string
-function M.to_string(lines)
- local text = table.concat(lines, '\n')
- if has_final_newline(lines) then
- text = text .. '\n'
- end
- return text
-end
-
-return M
diff --git a/lua/diffs/diff.lua b/lua/diffs/diff.lua
index 5c7ff644..f6d12011 100644
--- a/lua/diffs/diff.lua
+++ b/lua/diffs/diff.lua
@@ -44,7 +44,7 @@ function M.extract_change_groups(hunk_lines)
elseif prefix == '+' then
in_del = false
table.insert(add_buf, { idx = i, text = line:sub(2) })
- else
+ elseif not line:match('^\\ No newline') then
if #del_buf > 0 and #add_buf > 0 then
table.insert(groups, { del_lines = del_buf, add_lines = add_buf })
end
diff --git a/lua/diffs/diffargs.lua b/lua/diffs/diffargs.lua
deleted file mode 100644
index 8283c1ff..00000000
--- a/lua/diffs/diffargs.lua
+++ /dev/null
@@ -1,239 +0,0 @@
-local M = {}
-
-local diffspec = require('diffs.spec')
-
----@class diffs.DiffParseContext
----@field path string
----@field current? diffs.Endpoint
----@field default_layout? "unified"|"stacked"|"split"
-
----@class diffs.DiffParseResult
----@field spec diffs.DiffSpec
----@field layout "unified"|"stacked"|"split"
-
-local function normalize_rev(rev)
- if rev == '@' then
- return 'HEAD'
- end
- return rev
-end
-
----@param args? string
----@return string[]
-local function split_args(args)
- args = vim.trim(args or '')
- if args == '' then
- return {}
- end
- return vim.split(args, '%s+', { trimempty = true })
-end
-
----@param tokens string[]
----@param default_layout? "unified"|"stacked"|"split"
----@return ("unified"|"stacked"|"split")?, string?
-local function take_layout(tokens, default_layout)
- local layout = default_layout or 'unified'
- while tokens[1] and tokens[1]:match('^%+%+') do
- if tokens[1]:match('^%+%+layout=') then
- layout = tokens[1]:match('^%+%+layout=(.*)$')
- table.remove(tokens, 1)
- else
- return nil, 'unknown option ' .. tokens[1]
- end
- end
- return layout
-end
-
----@param endpoint diffs.Endpoint
----@return boolean
-local function is_index(endpoint)
- return endpoint.kind == diffspec.endpoint_kind.index
-end
-
----@param endpoint diffs.Endpoint
----@return boolean
-local function is_worktree(endpoint)
- return endpoint.kind == diffspec.endpoint_kind.worktree
-end
-
----@class diffs.DiffParsedObject
----@field left diffs.Endpoint
----@field path? string # explicit path; nil means the current file
----@field right_worktree? boolean # if set, the right endpoint is the worktree rather than `current`
-
---- Parse a single `:Diff` object into its left endpoint, optional explicit
---- path, and whether the right endpoint should be pinned to the worktree.
---- Recognizable Fugitive forms that are intentionally unsupported are rejected
---- with specific messages (see |diffs.nvim-diff-objects|).
----@param object string
----@return diffs.DiffParsedObject?, string?
-local function parse_object(object)
- if object == '#' or object:match('^#%d+$') then
- return nil, 'alternate-buffer objects (#) are not supported'
- end
- if object:sub(1, 1) == '!' then
- return nil, 'owner-commit objects (!) are not supported'
- end
- if object == '' then
- return nil, ' objects are not supported'
- end
- if object == '-' then
- return nil, 'the previous-object form (-) is not supported'
- end
- if object == '.' or object:sub(1, 2) == './' or object:sub(1, 3) == '../' then
- return nil, 'worktree-relative path objects (./) are not supported'
- end
- if object:find('..', 1, true) then
- return nil, 'range objects are not supported; use :Diff review for ranges'
- end
- if object:match('^:/') then
- return nil, 'commit-message search objects (:/) are not supported'
- end
- if object:match('^:%(') then
- return nil, 'pathspec-magic objects (:(...)) are not supported'
- end
-
- if object == ':' or object == ':%' or object == ':0:%' then
- return { left = diffspec.index() }, nil
- end
-
- local stage_digit, stage_rest = object:match('^:([0-3]):(.+)$')
- if stage_digit then
- local left
- if stage_digit == '0' then
- left = diffspec.index()
- elseif stage_digit == '1' then
- left = diffspec.stage(1)
- elseif stage_digit == '2' then
- left = diffspec.stage(2)
- else
- left = diffspec.stage(3)
- end
- return {
- left = left,
- path = stage_rest ~= '%' and stage_rest or nil,
- right_worktree = true,
- },
- nil
- end
-
- if object:match('^:[123]$') then
- return nil,
- 'merge stage ' .. object .. ' needs a path; use ' .. object .. ':% for the current file'
- end
-
- if object:sub(1, 1) == ':' then
- local object_path = object:sub(2)
- if object_path == '%' then
- return { left = diffspec.index() }, nil
- end
- return { left = diffspec.index(), path = object_path, right_worktree = true }, nil
- end
-
- if object:match(':$') then
- return nil, 'tree objects (trailing :) are not supported'
- end
-
- -- Greedy: `a:b:c` splits at the last colon. Git revisions rarely contain
- -- colons, and an invalid revision surfaces later when its content is read.
- local rev, rev_path = object:match('^(.+):(.+)$')
- if rev then
- if rev_path == '%' then
- return { left = diffspec.tree(normalize_rev(rev)) }, nil
- end
- return {
- left = diffspec.tree(normalize_rev(rev)),
- path = rev_path,
- right_worktree = true,
- },
- nil
- end
-
- return { left = diffspec.tree(normalize_rev(object)) }, nil
-end
-
----@param current diffs.Endpoint
----@param path string
----@return diffs.DiffSpec
-local function default_spec(current, path)
- if is_worktree(current) then
- return diffspec.index_to_worktree(path)
- end
- if is_index(current) then
- return diffspec.head_to_index(path)
- end
- return diffspec.file(diffspec.worktree(), current, path)
-end
-
----@param args? string
----@param context diffs.DiffParseContext
----@return diffs.DiffParseResult?, string?
-function M.parse(args, context)
- local path = context.path
-
- local current = context.current and diffspec.endpoint(context.current) or diffspec.worktree()
- local tokens = split_args(args)
- local layout, layout_err = take_layout(tokens, context.default_layout)
- if not layout then
- return nil, layout_err
- end
-
- if tokens[1] and tokens[1]:match('^%-%-') then
- return nil, 'unsupported option ' .. tokens[1]
- end
-
- if #tokens > 1 then
- return nil, 'expected at most one Fugitive object'
- end
-
- if #tokens == 0 then
- return {
- spec = default_spec(current, path),
- layout = layout,
- }, nil
- end
-
- local object, err = parse_object(tokens[1])
- if not object then
- return nil, err
- end
-
- local scope_path = object.path or path
- local right = object.right_worktree and diffspec.worktree() or current
- return {
- spec = diffspec.file(object.left, right, scope_path),
- layout = layout,
- }, nil
-end
-
----@class diffs.DiffFilesParseResult
----@field left? string # old/left side path, as typed; nil means the current buffer's file on disk
----@field right? string # new/right side path, as typed; nil means the current buffer
----@field layout "unified"|"stacked"|"split"
-
----@param args? string
----@return diffs.DiffFilesParseResult?, string?
-function M.parse_files(args)
- local tokens = split_args(args)
- local layout, layout_err = take_layout(tokens)
- if not layout then
- return nil, layout_err
- end
-
- if #tokens > 2 then
- return nil, 'expected at most two file paths'
- end
-
- return {
- left = tokens[1],
- right = tokens[2],
- layout = layout,
- }, nil
-end
-
-M._test = {
- normalize_rev = normalize_rev,
- split_args = split_args,
-}
-
-return M
diff --git a/lua/diffs/diffopt.lua b/lua/diffs/diffopt.lua
index ca1e43c5..de45a61c 100644
--- a/lua/diffs/diffopt.lua
+++ b/lua/diffs/diffopt.lua
@@ -68,29 +68,4 @@ function M.vim_diff_opts()
return opts
end
---- Equivalent git diff flags for the resolved options. linematch has no git
---- counterpart and is omitted, and inline only shapes highlighting, not the
---- diff git is asked to produce.
----@return string[]
-function M.git_flags()
- local opts = M.resolve()
- local flags = {}
- if opts.ignore_whitespace then
- flags[#flags + 1] = '--ignore-all-space'
- end
- if opts.ignore_whitespace_change then
- flags[#flags + 1] = '--ignore-space-change'
- end
- if opts.ignore_whitespace_change_at_eol then
- flags[#flags + 1] = '--ignore-space-at-eol'
- end
- if opts.ignore_blank_lines then
- flags[#flags + 1] = '--ignore-blank-lines'
- end
- if opts.algorithm then
- flags[#flags + 1] = '--diff-algorithm=' .. opts.algorithm
- end
- return flags
-end
-
return M
diff --git a/lua/diffs/difftastic.lua b/lua/diffs/difftastic.lua
deleted file mode 100644
index 5570ac61..00000000
--- a/lua/diffs/difftastic.lua
+++ /dev/null
@@ -1,400 +0,0 @@
-local M = {}
-
-local diffopt = require('diffs.diffopt')
-
-local hunk_model = require('diffs.hunks')
-local log = require('diffs.log')
-local runtime = require('diffs.runtime')
-
-local dbg = log.dbg
-
-local unified_ns = vim.api.nvim_create_namespace('diffs_difftastic_unified')
-local active_var = 'diffs_difft_active'
-
-local COMMAND = 'difft'
-local TIMEOUT_MS = 5000
-
----@class diffs.DifftAlignment : diffs.SplitAlignment
----@field left_intra table
----@field right_intra table
----@field changed boolean
-
---- Mark a buffer as rendered by difftastic. Suppresses the decorator's own
---- intra step and short-circuits whitespace toggling (moot for a structural
---- diff).
----@param bufnr integer
-function M.mark_active(bufnr)
- pcall(vim.api.nvim_buf_set_var, bufnr, active_var, true)
-end
-
----@param bufnr integer
-function M.clear_active(bufnr)
- pcall(vim.api.nvim_buf_del_var, bufnr, active_var)
- pcall(vim.api.nvim_buf_clear_namespace, bufnr, unified_ns, 0, -1)
-end
-
----@param bufnr integer
----@return boolean
-function M.is_active(bufnr)
- local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, active_var)
- return ok and value == true
-end
-
----@return { enabled: boolean, args: string[] }
-function M.resolve()
- local raw = runtime.get_difftastic_config()
- if raw == nil or raw == false then
- return { enabled = false, args = {} }
- end
- local args = {}
- if type(raw) == 'table' and type(raw.args) == 'table' then
- args = raw.args
- end
- return { enabled = true, args = args }
-end
-
----@return boolean
-function M.available()
- return M.resolve().enabled and vim.fn.executable(COMMAND) == 1
-end
-
----@param value any
----@return any
-local function nilify(value)
- if value == nil or value == vim.NIL then
- return nil
- end
- return value
-end
-
---- Coerce a ContentLines (a list with extra non-integer metadata keys) into a
---- plain string[] that vim.fn.writefile can convert to a Vim list.
----@param lines string[]|diffs.ContentLines
----@return string[]
-local function plain_lines(lines)
- local out = {}
- for i, line in ipairs(lines) do
- out[i] = line
- end
- return out
-end
-
----@param old_lines string[]
----@param new_lines string[]
----@param relpath string
----@return string, string, string temp dir, old path, new path
-local function write_pair(old_lines, new_lines, relpath)
- local tmp = vim.fn.tempname()
- vim.fn.mkdir(tmp, 'p')
- local base = vim.fn.fnamemodify(relpath, ':t')
- if base == '' then
- base = 'file'
- end
- local old_file = tmp .. '/old_' .. base
- local new_file = tmp .. '/new_' .. base
- vim.fn.writefile(plain_lines(old_lines), old_file)
- vim.fn.writefile(plain_lines(new_lines), new_file)
- return tmp, old_file, new_file
-end
-
---- Run difft on two file paths and return the decoded JSON, or nil + error.
---- We always force JSON output, no color, and strip-cr off (so difft's byte
---- offsets stay aligned with our exact line bytes); user `args` are appended.
----@param old_path string
----@param new_path string
----@return table?, string?
-function M.run_json(old_path, new_path)
- local cfg = M.resolve()
- if not cfg.enabled then
- return nil, 'difftastic disabled'
- end
-
- local cmd = { COMMAND, '--display', 'json', '--color', 'never', '--strip-cr', 'off' }
- for _, arg in ipairs(cfg.args) do
- cmd[#cmd + 1] = arg
- end
- cmd[#cmd + 1] = old_path
- cmd[#cmd + 1] = new_path
-
- local env = vim.tbl_extend('force', vim.fn.environ(), { DFT_UNSTABLE = 'yes' })
- local ok, result = pcall(function()
- return vim.system(cmd, { text = true, env = env }):wait(TIMEOUT_MS)
- end)
- if not ok then
- return nil, 'difft invocation failed: ' .. tostring(result)
- end
- if result.code ~= 0 and result.code ~= 1 then
- return nil, 'difft exited ' .. tostring(result.code) .. ': ' .. (result.stderr or '')
- end
- if not result.stdout or result.stdout == '' then
- return nil, 'difft produced no output'
- end
-
- local decoded_ok, decoded = pcall(vim.json.decode, result.stdout)
- if not decoded_ok then
- return nil, 'invalid difft json: ' .. tostring(decoded)
- end
- if type(decoded) == 'table' and decoded.aligned_lines == nil and decoded[1] ~= nil then
- decoded = decoded[1]
- end
- if type(decoded) ~= 'table' then
- return nil, 'unexpected difft json shape'
- end
- if decoded.status == 'created' or decoded.status == 'deleted' then
- return nil, 'difftastic: no structural diff for ' .. decoded.status .. ' file'
- end
- if decoded.status ~= 'unchanged' and (decoded.aligned_lines == nil or decoded.chunks == nil) then
- return nil, 'unexpected difft json shape'
- end
- if type(decoded.language) == 'string' and decoded.language:match('^Text') then
- dbg('difftastic line-diff fallback: %s', decoded.language)
- end
- return decoded
-end
-
---- Per-line change spans, keyed by 0-based line number. Robust to difft's
---- unordered chunk entries (we key by line_number, never rely on order).
----@param json table
----@return table, table
-function M.span_maps(json)
- local lhs, rhs = {}, {}
- local function collect(side_map, side)
- local entry = nilify(side)
- if not entry then
- return
- end
- local line = nilify(entry.line_number)
- if line == nil then
- return
- end
- local spans = {}
- for _, change in ipairs(entry.changes or {}) do
- local s = nilify(change.start)
- local e = nilify(change['end'])
- if type(s) == 'number' and type(e) == 'number' and e > s then
- spans[#spans + 1] = { col_start = s, col_end = e }
- end
- end
- side_map[line] = spans
- end
- for _, chunk in ipairs(json.chunks or {}) do
- for _, row in ipairs(chunk) do
- collect(lhs, row.lhs)
- collect(rhs, row.rhs)
- end
- end
- return lhs, rhs
-end
-
----@param maps table
----@return boolean
-local function map_has_spans(maps)
- return next(maps) ~= nil
-end
-
----@param lhs table?
----@param rhs table?
----@return boolean
-function M.has_changes(lhs, rhs)
- return (lhs ~= nil and map_has_spans(lhs)) or (rhs ~= nil and map_has_spans(rhs))
-end
-
----@param spans {col_start: integer, col_end: integer}[]?
----@return boolean
-local function has_spans(spans)
- return spans ~= nil and #spans > 0
-end
-
---- Build a split-compatible alignment (diffs.SplitAlignment shape) from
---- difftastic's whole-file structural alignment, plus per-row intra spans.
----@param json table
----@param old_lines string[]
----@param new_lines string[]
----@return diffs.DifftAlignment
-function M.build_alignment(json, old_lines, new_lines)
- local lhs, rhs = M.span_maps(json)
- local left_lines, right_lines = {}, {}
- local left_rows, right_rows = {}, {}
- local left_intra, right_intra = {}, {}
- local anchors = {}
- local prev_changed = false
- local changed = false
-
- for _, pair in ipairs(json.aligned_lines or {}) do
- local l, r = nilify(pair[1]), nilify(pair[2])
- local left_content = l ~= nil and old_lines[l + 1] or nil
- local right_content = r ~= nil and new_lines[r + 1] or nil
- local left_past = l ~= nil and left_content == nil
- local right_past = r ~= nil and right_content == nil
- local both_virtual = (l == nil or left_past) and (r == nil or right_past)
-
- if not both_virtual then
- local left_changes = (l ~= nil and not left_past) and lhs[l] or nil
- local right_changes = (r ~= nil and not right_past) and rhs[r] or nil
- local left_filler = l == nil or left_past
- local right_filler = r == nil or right_past
-
- local left_kind = left_filler and 'filler'
- or (has_spans(left_changes) and 'delete' or 'context')
- local right_kind = right_filler and 'filler'
- or (has_spans(right_changes) and 'add' or 'context')
-
- local idx = #left_lines + 1
- left_lines[idx] = left_content or ''
- right_lines[idx] = right_content or ''
- left_rows[idx] = {
- kind = left_kind,
- old_lnum = (not left_filler) and (l + 1) or nil,
- new_lnum = (not left_filler and r ~= nil and not right_past) and (r + 1) or nil,
- }
- right_rows[idx] = {
- kind = right_kind,
- old_lnum = (not right_filler and l ~= nil and not left_past) and (l + 1) or nil,
- new_lnum = (not right_filler) and (r + 1) or nil,
- }
- if has_spans(left_changes) then
- left_intra[idx] = left_changes
- end
- if has_spans(right_changes) then
- right_intra[idx] = right_changes
- end
-
- local row_changed = left_kind == 'delete'
- or left_kind == 'filler'
- or right_kind == 'add'
- or right_kind == 'filler'
- if row_changed then
- changed = true
- if not prev_changed then
- anchors[#anchors + 1] = idx
- end
- end
- prev_changed = row_changed
- end
- end
-
- return {
- left_lines = left_lines,
- right_lines = right_lines,
- left_rows = left_rows,
- right_rows = right_rows,
- anchors = anchors,
- left_intra = left_intra,
- right_intra = right_intra,
- changed = changed,
- }
-end
-
---- Structural alignment for the split layout, from two content arrays.
----@param old_lines string[]
----@param new_lines string[]
----@param relpath string
----@return diffs.DifftAlignment?, string?
-function M.align(old_lines, new_lines, relpath)
- old_lines = plain_lines(old_lines)
- new_lines = plain_lines(new_lines)
- local tmp, old_file, new_file = write_pair(old_lines, new_lines, relpath)
- local json, err = M.run_json(old_file, new_file)
- pcall(vim.fn.delete, tmp, 'rf')
- if not json then
- dbg('difftastic align failed: %s', err or 'unknown')
- return nil, err
- end
- return M.build_alignment(json, old_lines, new_lines)
-end
-
---- Per-line span maps for two content arrays (materialized to temp files).
----@param old_lines string[]
----@param new_lines string[]
----@param relpath string
----@return table?, table?, string?
-function M.span_maps_for_content(old_lines, new_lines, relpath)
- local tmp, old_file, new_file = write_pair(old_lines, new_lines, relpath)
- local json, err = M.run_json(old_file, new_file)
- pcall(vim.fn.delete, tmp, 'rf')
- if not json then
- dbg('difftastic span maps failed: %s', err or 'unknown')
- return nil, nil, err
- end
- local lhs, rhs = M.span_maps(json)
- return lhs, rhs
-end
-
---- Per-line span maps for two real file paths (`:Diff files` — no temp files).
----@param left_path string
----@param right_path string
----@return table?, table?, string?
-function M.span_maps_for_paths(left_path, right_path)
- local json, err = M.run_json(left_path, right_path)
- if not json then
- dbg('difftastic span maps failed: %s', err or 'unknown')
- return nil, nil, err
- end
- local lhs, rhs = M.span_maps(json)
- return lhs, rhs
-end
-
---- Paint structural intra-line spans onto a generated unified diffs:// buffer,
---- mapping difft's per-line spans onto the parsed hunk lines. Spans land in a
---- dedicated namespace; the decorator's own intra step is suppressed for the
---- buffer (diffs_difft_active). Content columns are offset past the rail and the
---- one-character diff prefix (rail_width + 1).
----@param bufnr integer
----@param lhs table
----@param rhs table
----@param diff_lines string[]
----@param spec diffs.DiffSpec?
----@param rail_width integer
----@return integer applied span count
-function M.apply_unified(bufnr, lhs, rhs, diff_lines, spec, rail_width)
- local opts = runtime.get_highlight_opts()
- local priority = opts.highlights.priorities.char_bg
- local base = rail_width + 1
-
- vim.api.nvim_buf_clear_namespace(bufnr, unified_ns, 0, -1)
- M.mark_active(bufnr)
-
- if diffopt.inline() == 'none' then
- return 0
- end
-
- local line_count = vim.api.nvim_buf_line_count(bufnr)
- local applied = 0
- for _, hunk in ipairs(hunk_model.parse(diff_lines, spec)) do
- for _, line in ipairs(hunk.lines) do
- local spans, hl
- if line.kind == 'delete' and line.old_lnum then
- spans, hl = lhs[line.old_lnum - 1], 'DiffsDeleteText'
- elseif line.kind == 'add' and line.new_lnum then
- spans, hl = rhs[line.new_lnum - 1], 'DiffsAddText'
- end
- if spans and line.lnum >= 1 and line.lnum <= line_count then
- for _, span in ipairs(spans) do
- local ok = pcall(
- vim.api.nvim_buf_set_extmark,
- bufnr,
- unified_ns,
- line.lnum - 1,
- base + span.col_start,
- {
- end_col = base + span.col_end,
- hl_group = hl,
- priority = priority,
- }
- )
- if ok then
- applied = applied + 1
- end
- end
- end
- end
- end
- return applied
-end
-
-M._test = {
- nilify = nilify,
- plain_lines = plain_lines,
-}
-
-return M
diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua
deleted file mode 100644
index eca27ede..00000000
--- a/lua/diffs/fugitive.lua
+++ /dev/null
@@ -1,284 +0,0 @@
-local M = {}
-
-local commands = require('diffs.commands')
-local git = require('diffs.git')
-local keymaps = require('diffs.keymaps')
-local log = require('diffs.log')
-
-local dbg = log.dbg
-local notify = log.notify
-
----@type table>
-local buffer_keymaps = {}
-
----@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil
-
----@param bufnr integer
----@param lnum integer
----@return diffs.FugitiveSection
-function M.get_section_at_line(bufnr, lnum)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
-
- for i = #lines, 1, -1 do
- local line = lines[i]
- if line:match('^Staged ') then
- return 'staged'
- elseif line:match('^Unstaged ') then
- return 'unstaged'
- elseif line:match('^Untracked ') then
- return 'untracked'
- end
- end
-
- return nil
-end
-
----@param s string
----@return string
-local function unquote(s)
- if s:sub(1, 1) ~= '"' then
- return s
- end
- local inner = s:sub(2, -2)
- local result = {}
- local i = 1
- while i <= #inner do
- if inner:sub(i, i) == '\\' and i < #inner then
- local next_char = inner:sub(i + 1, i + 1)
- if next_char == 'n' then
- table.insert(result, '\n')
- i = i + 2
- elseif next_char == 't' then
- table.insert(result, '\t')
- i = i + 2
- elseif next_char == '"' then
- table.insert(result, '"')
- i = i + 2
- elseif next_char == '\\' then
- table.insert(result, '\\')
- i = i + 2
- elseif next_char:match('%d') then
- local oct = inner:match('^(%d%d%d)', i + 1)
- if oct then
- table.insert(result, string.char(tonumber(oct, 8)))
- i = i + 4
- else
- table.insert(result, next_char)
- i = i + 2
- end
- else
- table.insert(result, next_char)
- i = i + 2
- end
- else
- table.insert(result, inner:sub(i, i))
- i = i + 1
- end
- end
- return table.concat(result)
-end
-
----@param line string
----@return string?, string?, string?
-local function parse_file_line(line)
- local rename_status, old, new = line:match('^([RC])%d*%s+(.-)%s+->%s+(.+)$')
- if old and new then
- return unquote(vim.trim(new)), unquote(vim.trim(old)), rename_status
- end
-
- local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$')
- if status and filename then
- return unquote(vim.trim(filename)), nil, status
- end
-
- return nil, nil, nil
-end
-
----@param line string
----@return diffs.FugitiveSection?
-local function parse_section_header(line)
- if line:match('^Staged %(%d') then
- return 'staged'
- elseif line:match('^Unstaged %(%d') then
- return 'unstaged'
- elseif line:match('^Untracked %(%d') then
- return 'untracked'
- end
- return nil
-end
-
----@param bufnr integer
----@param lnum integer
----@return string?, diffs.FugitiveSection, boolean, string?, string?
-function M.get_file_at_line(bufnr, lnum)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- local current_line = lines[lnum]
-
- if not current_line then
- return nil, nil, false, nil, nil
- end
-
- local section_header = parse_section_header(current_line)
- if section_header then
- return nil, section_header, true, nil, nil
- end
-
- local filename, old_filename, status = parse_file_line(current_line)
- if filename then
- local section = M.get_section_at_line(bufnr, lnum)
- return filename, section, false, old_filename, status
- end
-
- local prefix = current_line:sub(1, 1)
- if prefix == '+' or prefix == '-' or prefix == ' ' then
- for i = lnum - 1, 1, -1 do
- local prev_line = lines[i]
- filename, old_filename, status = parse_file_line(prev_line)
- if filename then
- local section = M.get_section_at_line(bufnr, i)
- return filename, section, false, old_filename, status
- end
- if prev_line:match('^%w+ %(') or prev_line == '' then
- break
- end
- end
- end
-
- return nil, nil, false, nil, nil
-end
-
----@class diffs.HunkPosition
----@field hunk_header string
----@field offset integer
-
----@param bufnr integer
----@param lnum integer
----@return diffs.HunkPosition?
-function M.get_hunk_position(bufnr, lnum)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false)
- local current = lines[lnum]
-
- if not current then
- return nil
- end
-
- local prefix = current:sub(1, 1)
- if prefix ~= '+' and prefix ~= '-' and prefix ~= ' ' then
- return nil
- end
-
- for i = lnum - 1, 1, -1 do
- local line = lines[i]
- if line:match('^@@.-@@') then
- return {
- hunk_header = line,
- offset = lnum - i,
- }
- end
- if line:match('^[MADRCU?!]%s') or line:match('^%w+ %(') then
- break
- end
- end
-
- return nil
-end
-
----@param bufnr integer
----@return string?
-local function get_repo_root_from_fugitive(bufnr)
- local bufname = vim.api.nvim_buf_get_name(bufnr)
- local fugitive_path = bufname:match('^fugitive://(.+)///')
- if fugitive_path then
- return fugitive_path
- end
-
- local cwd = vim.fn.getcwd()
- local root = git.get_repo_root(cwd .. '/.')
- return root
-end
-
----@param vertical boolean
-function M.diff_file_under_cursor(vertical)
- local bufnr = vim.api.nvim_get_current_buf()
- local lnum = vim.api.nvim_win_get_cursor(0)[1]
-
- local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum)
-
- local repo_root = get_repo_root_from_fugitive(bufnr)
- if not repo_root then
- notify('could not determine repository root', vim.log.levels.ERROR)
- return
- end
-
- if is_header then
- dbg('diff_section: %s', section or 'unknown')
- if section == 'untracked' then
- notify(
- 'cannot diff untracked section; open an untracked file line instead',
- vim.log.levels.WARN
- )
- return
- end
- commands.diff_section(repo_root, {
- vertical = vertical,
- staged = section == 'staged',
- })
- return
- end
-
- if not filename then
- notify('no file under cursor', vim.log.levels.WARN)
- return
- end
-
- local filepath = repo_root .. '/' .. filename
- local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil
- local hunk_position = M.get_hunk_position(bufnr, lnum)
-
- dbg(
- 'diff_file_under_cursor: %s (section: %s, old: %s, hunk_offset: %s)',
- filename,
- section or 'unknown',
- old_filename or 'none',
- hunk_position and tostring(hunk_position.offset) or 'none'
- )
-
- commands.diff_file(filepath, {
- vertical = vertical,
- staged = section == 'staged',
- untracked = section == 'untracked',
- unmerged = status == 'U',
- old_filepath = old_filepath,
- hunk_position = hunk_position,
- })
-end
-
----@param bufnr integer
-function M.setup_keymaps(bufnr)
- keymaps.set_buffer_keymaps(buffer_keymaps, bufnr, {
- {
- 'du',
- function()
- M.diff_file_under_cursor(false)
- end,
- { desc = 'Unified diff (horizontal)' },
- },
- {
- 'dU',
- function()
- M.diff_file_under_cursor(true)
- end,
- { desc = 'Unified diff (vertical)' },
- },
- })
-
- vim.api.nvim_create_autocmd('BufWipeout', {
- buffer = bufnr,
- callback = function()
- keymaps.clear_buffer_keymaps(buffer_keymaps, bufnr)
- end,
- })
- dbg('set fugitive keymaps for buffer %d', bufnr)
-end
-
-return M
diff --git a/lua/diffs/generated/init.lua b/lua/diffs/generated/init.lua
deleted file mode 100644
index 9d2d5c47..00000000
--- a/lua/diffs/generated/init.lua
+++ /dev/null
@@ -1 +0,0 @@
-return require('diffs.generated.source')
diff --git a/lua/diffs/generated/source.lua b/lua/diffs/generated/source.lua
deleted file mode 100644
index 4eb3a213..00000000
--- a/lua/diffs/generated/source.lua
+++ /dev/null
@@ -1,323 +0,0 @@
-local M = {}
-
-local diffspec = require('diffs.spec')
-local hunk_model = require('diffs.hunks')
-
-local group = vim.api.nvim_create_augroup('diffs_generated_source', { clear = false })
-
----@type table>
-local restorable = {}
-
----@param bufnr integer
----@return table
-local function tracked_vars(bufnr)
- local vars = restorable[bufnr]
- if vars then
- return vars
- end
-
- vars = {}
- restorable[bufnr] = vars
- vim.api.nvim_create_autocmd('BufWipeout', {
- group = group,
- buffer = bufnr,
- once = true,
- callback = function()
- restorable[bufnr] = nil
- end,
- })
- return vars
-end
-
----@param bufnr integer
----@param name string
----@return any
-function M.get_var(bufnr, name)
- local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name)
- if ok then
- return value
- end
- return nil
-end
-
----@param bufnr integer
----@param name string
----@param value any
-function M.set_var(bufnr, name, value)
- vim.api.nvim_buf_set_var(bufnr, name, value)
- tracked_vars(bufnr)[name] = value
-end
-
----@param bufnr integer
----@param name string
-function M.del_var(bufnr, name)
- local vars = restorable[bufnr]
- if vars then
- vars[name] = nil
- end
- pcall(vim.api.nvim_buf_del_var, bufnr, name)
-end
-
----@param bufnr integer
-function M.restore(bufnr)
- for name, value in pairs(restorable[bufnr] or {}) do
- if M.get_var(bufnr, name) == nil then
- pcall(vim.api.nvim_buf_set_var, bufnr, name, value)
- end
- end
-end
-
----@param bufnr integer
----@return boolean, any
-function M.raw_hunks(bufnr)
- return pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_hunks')
-end
-
----@param bufnr integer
----@return diffs.DiffHunk[]
-function M.hunks(bufnr)
- local ok, parsed = M.raw_hunks(bufnr)
- if ok and type(parsed) == 'table' then
- return parsed
- end
- return {}
-end
-
----@param bufnr integer
----@param hunks diffs.DiffHunk[]
-function M.set_hunks(bufnr, hunks)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', hunks)
-end
-
----@param bufnr integer
----@param diff_lines string[]
----@param diff_spec diffs.DiffSpec
-function M.set_hunks_from_lines(bufnr, diff_lines, diff_spec)
- M.set_hunks(bufnr, hunk_model.parse(diff_lines, diff_spec))
-end
-
----@param bufnr integer
-function M.clear_hunks(bufnr)
- pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_hunks')
-end
-
----@class diffs.GeneratedBufferSource
----@field version integer
----@field kind "file"|"file_pair"|"section"|"review"|"unmerged"|"split_endpoint"|"files"
----@field repo_root? string
----@field spec? diffs.DiffSpec
----@field edge? "staged"|"unstaged"
----@field path? string
----@field old_path? string
----@field section? "staged"|"unstaged"
----@field review? diffs.ReviewSpec
----@field working_path? string
----@field side? "left"|"right"
----@field filetype? string
----@field quickfix? boolean
----@field left_path? string
----@field right_path? string
----@field left_name? string
----@field right_name? string
----@field right_buf? integer
----@field worktree_buf? integer
-
----@param source table
----@return diffs.GeneratedBufferSource?
-function M.normalize_source(source)
- if type(source) ~= 'table' then
- error('expected table')
- end
- if source.version ~= 1 then
- error('expected version 1')
- end
- if source.kind ~= 'files' then
- if type(source.repo_root) ~= 'string' or source.repo_root == '' then
- error('expected repo_root')
- end
- end
-
- if source.kind == 'file' then
- if source.spec == nil then
- error('expected file spec')
- end
- source.spec = diffspec.new(source.spec)
- elseif source.kind == 'split_endpoint' then
- if source.spec == nil then
- error('expected split endpoint spec')
- end
- source.spec = diffspec.new(source.spec)
- if source.side ~= 'left' and source.side ~= 'right' then
- error('expected split endpoint side')
- end
- if type(source.path) ~= 'string' or source.path == '' then
- error('expected split endpoint path')
- end
- elseif source.kind == 'file_pair' then
- if source.edge ~= 'staged' and source.edge ~= 'unstaged' then
- error('expected file_pair edge')
- end
- if type(source.path) ~= 'string' or source.path == '' then
- error('expected file_pair path')
- end
- if type(source.old_path) ~= 'string' or source.old_path == '' then
- error('expected file_pair old_path')
- end
- elseif source.kind == 'section' then
- if source.section ~= 'staged' and source.section ~= 'unstaged' then
- error('expected section')
- end
- elseif source.kind == 'review' then
- if type(source.review) ~= 'table' then
- error('expected review spec')
- end
- elseif source.kind == 'unmerged' then
- if type(source.path) ~= 'string' or source.path == '' then
- error('expected unmerged path')
- end
- elseif source.kind == 'files' then
- if type(source.left_path) ~= 'string' or source.left_path == '' then
- error('expected files left_path')
- end
- if type(source.right_path) ~= 'string' or source.right_path == '' then
- error('expected files right_path')
- end
- if source.right_buf ~= nil and type(source.right_buf) ~= 'number' then
- error('expected files right_buf')
- end
- else
- error('unknown source kind')
- end
-
- return source
-end
-
----@param bufnr integer
----@return diffs.GeneratedBufferSource?, string?
-function M.source(bufnr)
- local raw = M.get_var(bufnr, 'diffs_source')
- if raw == nil and not vim.api.nvim_buf_is_loaded(bufnr) then
- local vars = restorable[bufnr]
- raw = vars and vars.diffs_source
- end
- if raw == nil then
- return nil, nil
- end
-
- local ok, source = pcall(M.normalize_source, raw)
- if not ok then
- return nil, tostring(source)
- end
-
- return source, nil
-end
-
----@param bufnr integer
----@param source diffs.GeneratedBufferSource|diffs.SplitEndpointSource
-function M.set_source(bufnr, source)
- M.set_var(bufnr, 'diffs_source', source)
-end
-
----@param repo_root string
----@param diff_spec diffs.DiffSpec
----@param worktree_buf? integer
----@return diffs.GeneratedBufferSource
-function M.file_source(repo_root, diff_spec, worktree_buf)
- return {
- version = 1,
- kind = 'file',
- repo_root = repo_root,
- spec = diff_spec,
- worktree_buf = worktree_buf,
- }
-end
-
----@param repo_root string
----@param edge "staged"|"unstaged"
----@param path string
----@param old_path string
----@return diffs.GeneratedBufferSource
-function M.file_pair_source(repo_root, edge, path, old_path)
- return {
- version = 1,
- kind = 'file_pair',
- repo_root = repo_root,
- edge = edge,
- path = path,
- old_path = old_path,
- }
-end
-
----@param repo_root string
----@param section "staged"|"unstaged"
----@return diffs.GeneratedBufferSource
-function M.section_source(repo_root, section)
- return {
- version = 1,
- kind = 'section',
- repo_root = repo_root,
- section = section,
- }
-end
-
----@param repo_root string
----@param path string
----@param working_path string?
----@return diffs.GeneratedBufferSource
-function M.unmerged_source(repo_root, path, working_path)
- return {
- version = 1,
- kind = 'unmerged',
- repo_root = repo_root,
- path = path,
- working_path = working_path,
- }
-end
-
----@param left_path string # absolute path of the old/left side
----@param right_path string # absolute path of the new/right side
----@param left_name string # display name for the old/left side
----@param right_name string # display name for the new/right side
----@param right_buf? integer # buffer whose content is the new/right side; nil reads the path
----@return diffs.GeneratedBufferSource
-function M.files_source(left_path, right_path, left_name, right_name, right_buf)
- return {
- version = 1,
- kind = 'files',
- left_path = left_path,
- right_path = right_path,
- left_name = left_name,
- right_name = right_name,
- right_buf = right_buf,
- }
-end
-
----@param repo_root string
----@param review diffs.ReviewSpec
----@return diffs.GeneratedBufferSource
-function M.review_source(repo_root, review)
- return {
- version = 1,
- kind = 'review',
- repo_root = repo_root,
- review = review,
- }
-end
-
----@param opts { repo_root: string, spec: diffs.DiffSpec, side: "left"|"right", path: string, filetype?: string, quickfix?: boolean, worktree_buf?: integer }
----@return diffs.SplitEndpointSource
-function M.split_endpoint_source(opts)
- return {
- version = 1,
- kind = 'split_endpoint',
- repo_root = opts.repo_root,
- spec = opts.spec,
- side = opts.side,
- path = opts.path,
- filetype = opts.filetype,
- quickfix = opts.quickfix,
- worktree_buf = opts.worktree_buf,
- }
-end
-
-return M
diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua
deleted file mode 100644
index cc7f459e..00000000
--- a/lua/diffs/git.lua
+++ /dev/null
@@ -1,233 +0,0 @@
-local M = {}
-
-local content = require('diffs.content')
-
-local repo_root_cache = {}
-
-local function is_index_stage(revision)
- return type(revision) == 'string' and revision:match('^:%d$') ~= nil
-end
-
----@param filepath? string
----@return string?
-function M.get_repo_root(filepath)
- local dir = filepath and vim.fn.fnamemodify(filepath, ':h') or vim.fn.getcwd()
- if repo_root_cache[dir] ~= nil then
- return repo_root_cache[dir]
- end
- local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
- if vim.v.shell_error ~= 0 then
- return nil
- end
- repo_root_cache[dir] = result[1]
- return result[1]
-end
-
----@param repo_root string
----@param filepath string
----@return string?
-local function relative_path(repo_root, filepath)
- if vim.startswith(filepath, repo_root) then
- return filepath:sub(#repo_root + 2)
- end
- return vim.fn.fnamemodify(filepath, ':.')
-end
-
----@param revision string
----@param filepath string
----@return diffs.ContentLines?, string?
-function M.get_file_content(revision, filepath)
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return nil, 'not in a git repository'
- end
-
- local rel_path = relative_path(repo_root, filepath)
- local object = revision
- if revision:find(':', 1, true) and not is_index_stage(revision) then
- local resolved = vim.fn.systemlist({
- 'git',
- '-C',
- repo_root,
- 'rev-parse',
- '--verify',
- '--quiet',
- '--end-of-options',
- revision,
- })
- if vim.v.shell_error ~= 0 then
- return nil, 'failed to resolve revision: ' .. revision
- end
- object = resolved[1]
- end
-
- local result = vim.fn.systemlist(
- { 'git', '-C', repo_root, 'show', '--end-of-options', object .. ':' .. rel_path },
- nil,
- 1
- )
- if vim.v.shell_error ~= 0 then
- if not is_index_stage(revision) then
- vim.fn.system({
- 'git',
- '-C',
- repo_root,
- 'rev-parse',
- '--verify',
- '--end-of-options',
- object .. '^{tree}',
- })
- if vim.v.shell_error ~= 0 then
- return nil, 'failed to resolve revision: ' .. revision
- end
- end
- return nil, 'file not in revision: ' .. revision
- end
- return content.from_raw_lines(result), nil
-end
-
----@param filepath string
----@return string?
-function M.get_relative_path(filepath)
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return nil
- end
- return relative_path(repo_root, filepath)
-end
-
----@param filepath string
----@return diffs.ContentLines?, string?
-function M.get_index_content(filepath)
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return nil, 'not in a git repository'
- end
-
- local rel_path = M.get_relative_path(filepath)
- if not rel_path then
- return nil, 'could not determine relative path'
- end
-
- local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path }, nil, 1)
- if vim.v.shell_error ~= 0 then
- return nil, 'file not in index'
- end
- return content.from_raw_lines(result), nil
-end
-
----@param filepath string
----@return diffs.ContentLines?, string?
-function M.get_working_content(filepath)
- if vim.fn.filereadable(filepath) ~= 1 then
- return nil, 'file not readable'
- end
- local lines = vim.fn.readfile(filepath, 'b')
- return content.from_raw_lines(lines, { empty_is_empty = vim.fn.getfsize(filepath) == 0 }), nil
-end
-
----@param filepath string
----@return string?
-function M.get_index_mode(filepath)
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return nil
- end
-
- local rel_path = M.get_relative_path(filepath)
- if not rel_path then
- return nil
- end
-
- local result =
- vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path })
- if vim.v.shell_error ~= 0 or #result == 0 then
- return nil
- end
- return result[1]:match('^(%d+)')
-end
-
----@param revision string
----@param filepath string
----@return string?
-function M.get_tree_mode(revision, filepath)
- if is_index_stage(revision) then
- return nil
- end
-
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return nil
- end
-
- local rel_path = M.get_relative_path(filepath)
- if not rel_path then
- return nil
- end
-
- local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-tree', revision, '--', rel_path })
- if vim.v.shell_error ~= 0 or #result == 0 then
- return nil
- end
- return result[1]:match('^(%d+)')
-end
-
----@param filepath string
----@return string?
-function M.get_working_mode(filepath)
- if vim.fn.filereadable(filepath) ~= 1 then
- return nil
- end
-
- local perm = vim.fn.getfperm(filepath)
- if perm:sub(3, 3) == 'x' then
- return '100755'
- end
- return '100644'
-end
-
----@param filepath string
----@return boolean
-function M.is_unmerged(filepath)
- local repo_root = M.get_repo_root(filepath)
- if not repo_root then
- return false
- end
-
- local rel_path = relative_path(repo_root, filepath)
- local result =
- vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--unmerged', '--', rel_path })
- return vim.v.shell_error == 0 and #result > 0
-end
-
----@class diffs.GitApplyOpts
----@field cached? boolean
----@field reverse? boolean
----@field check? boolean
----@field recount? boolean
-
----@param repo_root string
----@param patch string
----@param opts? diffs.GitApplyOpts
----@return boolean, string[]
-function M.apply_patch(repo_root, patch, opts)
- opts = opts or {}
- local cmd = { 'git', '-C', repo_root, 'apply' }
- if opts.cached then
- table.insert(cmd, '--cached')
- end
- if opts.reverse then
- table.insert(cmd, '--reverse')
- end
- if opts.check then
- table.insert(cmd, '--check')
- end
- if opts.recount then
- table.insert(cmd, '--recount')
- end
-
- local output = vim.fn.systemlist(cmd, patch)
- return vim.v.shell_error == 0, output
-end
-
-return M
diff --git a/lua/diffs/health.lua b/lua/diffs/health.lua
index 7a6ecd47..06e2f447 100644
--- a/lua/diffs/health.lua
+++ b/lua/diffs/health.lua
@@ -9,20 +9,10 @@ function M.check()
vim.health.error('diffs.nvim requires Neovim 0.12.0+')
end
- local fugitive_loaded = vim.fn.exists(':Git') == 2
- if fugitive_loaded then
- vim.health.ok('vim-fugitive detected')
+ if pcall(vim.treesitter.language.add, 'diff') then
+ vim.health.ok('Treesitter diff parser available')
else
- vim.health.warn('vim-fugitive not detected (required for unified diff highlighting)')
- end
-
- local difftastic = require('diffs.difftastic')
- if not difftastic.resolve().enabled then
- vim.health.info('difftastic not enabled (optional, set integrations.difftastic)')
- elseif difftastic.available() then
- vim.health.ok('difftastic (difft) found on PATH')
- else
- vim.health.warn('integrations.difftastic is enabled but difft was not found on PATH')
+ vim.health.info('Treesitter diff parser unavailable. Diffs uses its Lua parser.')
end
end
diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua
index 5308fb14..ac9de971 100644
--- a/lua/diffs/highlight.lua
+++ b/lua/diffs/highlight.lua
@@ -2,28 +2,6 @@ local M = {}
local dbg = require('diffs.log').dbg
local diff = require('diffs.diff')
-local rails = require('diffs.rails')
-
----@param start_col integer?
----@param end_col integer?
----@param raw_len integer?
----@return integer?, integer?
-local function clamp_cols(start_col, end_col, raw_len)
- if not start_col or not end_col then
- return nil, nil
- end
- if raw_len then
- if start_col >= raw_len then
- return nil, nil
- end
- end_col = math.min(end_col, raw_len)
- end
- if end_col <= start_col then
- return nil, nil
- end
- return start_col, end_col
-end
-
local vim_syntax_cache = {}
local vim_syntax_cache_size = 0
local vim_syntax_cache_clock = 0
@@ -170,11 +148,13 @@ end
---@field highlights diffs.HunkHighlights
---@field defer_vim_syntax? boolean
---@field syntax_only? boolean
+---@field intra? boolean
---@class diffs.HunkOptsOverride
---@field highlights? table
---@field defer_vim_syntax? boolean
---@field syntax_only? boolean
+---@field intra? boolean
---@param config diffs.Config
---@param overrides? diffs.HunkOptsOverride
@@ -210,7 +190,8 @@ end
function M.highlight_hunk_prefixes(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
- local qw = hunk.quote_width or 0
+ local offset = hunk.column_offset or 0
+ local qw = (hunk.quote_width or 0) + offset
local extmark_count = 0
for i, line in ipairs(hunk.lines) do
@@ -396,7 +377,7 @@ local function apply_vim_syntax_spans(
leading_offset = leading_offset or 0
local hunk_line_count = #hunk.lines
- local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) - 1
+ local col_off = (hunk.prefix_width or 1) + (hunk.quote_width or 0) + (hunk.column_offset or 0) - 1
local extmark_count = 0
for _, span in ipairs(spans) do
local adj = span.line - leading_offset
@@ -485,7 +466,8 @@ end
function M.highlight_hunk(bufnr, ns, hunk, opts)
local p = opts.highlights.priorities
local pw = hunk.prefix_width or 1
- local qw = hunk.quote_width or 0
+ local offset = hunk.column_offset or 0
+ local qw = (hunk.quote_width or 0) + offset
local use_ts = hunk.lang and opts.highlights.treesitter
local use_vim = not use_ts and hunk.ft and opts.highlights.vim
@@ -543,7 +525,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif has_del and not has_add then
old_map[#old_code] = buf_line
table.insert(old_code, stripped)
- else
+ elseif prefix:match('^[ +%-]+$') then
new_map[#new_code] = buf_line
table.insert(new_code, stripped)
table.insert(old_code, stripped)
@@ -560,7 +542,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
bufnr,
ns,
hunk,
- hunk.header_context_col,
+ hunk.header_context_col + offset,
hunk.header_context,
hunk.lang,
new_code,
@@ -574,7 +556,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
elseif use_vim then
local code_lines = {}
for _, line in ipairs(hunk.lines) do
- table.insert(code_lines, line:sub(pw + 1))
+ table.insert(code_lines, line:sub(1, pw):match('^[ +%-]+$') and line:sub(pw + 1) or '')
end
if opts.defer_vim_syntax then
extmark_count, vim_cache_hit =
@@ -610,7 +592,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
local at_raw_line
- if (qw > 0 or pw > 1) and opts.highlights.treesitter then
+ if hunk.start_line > 0 and (qw > 0 or pw > 1) and opts.highlights.treesitter then
local at_buf_line = hunk.start_line - 1
at_raw_line = vim.api.nvim_buf_get_lines(bufnr, at_buf_line, at_buf_line + 1, false)[1]
end
@@ -618,11 +600,10 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
---@type diffs.IntraChanges?
local intra = nil
local intra_cfg = opts.highlights.intra
- local difft_active = vim.b[bufnr].diffs_difft_active == true
if
not opts.syntax_only
and intra_cfg
- and not difft_active
+ and opts.intra ~= false
and pw == 1
and (hunk._hl_line_count or #hunk.lines) <= intra_cfg.max_lines
then
@@ -664,7 +645,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
local ws_only = {}
if
not opts.syntax_only
- and not difft_active
+ and opts.intra ~= false
and pw == 1
and (hunk._hl_line_count or #hunk.lines) <= (intra_cfg and intra_cfg.max_lines or 500)
then
@@ -680,7 +661,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
then
for i = 0, #hunk.header_lines - 1 do
local buf_line = hunk.header_start_line - 1 + i
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = #hunk.header_lines[i + 1] + qw,
hl_group = 'DiffsClear',
priority = p.clear,
@@ -713,7 +694,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if (qw > 0 or pw > 1) and at_raw_line then
local at_buf_line = hunk.start_line - 1
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, at_buf_line, offset, {
end_col = #at_raw_line,
hl_group = 'DiffsClear',
priority = p.clear,
@@ -729,8 +710,8 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if use_ts and hunk.header_context and hunk.header_context_col then
local header_line = hunk.start_line - 1
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col, {
- end_col = hunk.header_context_col + #hunk.header_context,
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, header_line, hunk.header_context_col + offset, {
+ end_col = hunk.header_context_col + offset + #hunk.header_context,
hl_group = 'DiffsClear',
priority = p.clear,
})
@@ -753,26 +734,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
local dehl = ws_only[i] == true
local line_hl = is_diff_line and (has_add and 'DiffsAdd' or 'DiffsDelete') or nil
local prefix_hl = is_diff_line and (has_add and '@diff.plus' or '@diff.minus') or nil
- local bar_hl = is_diff_line and (has_add and 'DiffsAddBar' or 'DiffsDeleteBar') or nil
local dim_hl = is_diff_line and 'DiffsDim' or nil
local bg_hl = (dehl and dim_hl) or line_hl
- local is_marker = false
- if pw > 1 and line_hl and not prefix:find('[^+]') then
- local content = line:sub(pw + 1)
- is_marker = content:match('^<<<<<<<')
- or content:match('^=======')
- or content:match('^>>>>>>>')
- or content:match('^|||||||')
- end
-
- if not opts.syntax_only then
- if opts.hide_prefix and (not hunk.rail_width or is_diff_line) then
+ if not opts.syntax_only and prefix:match('^[ +%-]+$') then
+ if opts.hide_prefix then
local virt_hl = (opts.highlights.background and bg_hl) or nil
- local rail_width = hunk.rail_width or 0
- local hide_width = math.max(0, pw + qw - rail_width)
+ local hide_width = math.max(0, pw + qw - offset)
if hide_width > 0 then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, rail_width, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
virt_text = { { string.rep(' ', hide_width), virt_hl } },
virt_text_pos = 'overlay',
})
@@ -781,66 +751,14 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
if qw > 0 or pw > 1 then
local prefix_end = pw + qw
- if hunk.rail_width and not is_diff_line then
- prefix_end = hunk.rail_width
- end
if raw_len and prefix_end > raw_len then
prefix_end = raw_len
end
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = prefix_end,
hl_group = 'DiffsClear',
priority = p.clear,
})
- if hunk.rail_width and hunk.rail_width > 0 then
- local rail_end = hunk.rail_width
- if raw_len and rail_end > raw_len then
- rail_end = raw_len
- end
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
- end_col = rail_end,
- hl_group = 'DiffsRail',
- priority = p.syntax,
- })
-
- local rail_style = hunk.rail_style or 'dual'
- local rail_number_ranges = rails.number_ranges(hunk.rail_width, rail_style)
- if rail_number_ranges then
- for _, range in ipairs(rail_number_ranges) do
- local nr_start, nr_end = clamp_cols(range.start, range.finish, raw_len)
- if nr_start and nr_end then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, nr_start, {
- end_col = nr_end,
- hl_group = 'DiffsRailNr',
- priority = p.syntax + 1,
- })
- end
- end
- end
- local rail_nr_start, rail_nr_end, rail_nr_hl
- if (has_del or has_add) and rail_number_ranges and not dehl then
- local first_range = rail_number_ranges[1]
- local last_range = rail_style == 'single' and first_range
- or rail_number_ranges[#rail_number_ranges]
- if first_range and last_range then
- rail_nr_start = first_range.start
- rail_nr_end = last_range.finish
- rail_nr_hl = has_del and 'DiffsDeleteRailNr' or 'DiffsAddRailNr'
- end
- end
- if rail_nr_start and rail_nr_end and rail_nr_hl then
- local nr_start = rail_nr_start
- local nr_end = rail_nr_end
- nr_start, nr_end = clamp_cols(nr_start, nr_end, raw_len)
- if nr_start and nr_end then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, nr_start, {
- end_col = nr_end,
- hl_group = rail_nr_hl,
- priority = p.syntax + 2,
- })
- end
- end
- end
for ci = 0, pw - 1 do
local ch = line:sub(ci + 1, ci + 1)
if ch == '+' or ch == '-' then
@@ -856,24 +774,15 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
elseif opts.highlights.background and is_diff_line then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_col = 1,
hl_group = prefix_hl,
priority = p.syntax,
})
end
- if hunk.rail_width and is_diff_line and opts.highlights.background and not dehl then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
- virt_text = { { '▏', bar_hl } },
- virt_text_pos = 'overlay',
- hl_mode = 'replace',
- priority = p.char_bg + 10,
- })
- end
-
if opts.highlights.background and is_diff_line then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
+ pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, offset, {
end_row = buf_line + 1,
end_col = 0,
hl_group = bg_hl,
@@ -882,14 +791,6 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
})
end
- if is_marker and line_len > pw then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, {
- end_col = line_len + qw,
- hl_group = 'DiffsConflictMarker',
- priority = p.char_bg,
- })
- end
-
if char_spans_by_line[i] and not dehl then
local char_hl = has_add and 'DiffsAddText' or 'DiffsDeleteText'
for _, span in ipairs(char_spans_by_line[i]) do
@@ -916,7 +817,7 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
end
- if not hunk.rail_width and line_len > pw and covered_lines[buf_line] then
+ if line_len > pw and covered_lines[buf_line] then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, pw + qw, {
end_col = line_len + qw,
hl_group = 'DiffsClear',
diff --git a/lua/diffs/hunks.lua b/lua/diffs/hunks.lua
deleted file mode 100644
index a61488da..00000000
--- a/lua/diffs/hunks.lua
+++ /dev/null
@@ -1,590 +0,0 @@
-local M = {}
-
-local diffspec = require('diffs.spec')
-local notify = require('diffs.log').notify
-
----@class diffs.DiffHunkRange
----@field start integer
----@field count integer
----@field finish integer
-
----@class diffs.DiffHunkLine
----@field lnum integer
----@field kind "header"|"add"|"delete"|"context"|"meta"
----@field text string
----@field file string?
----@field old_lnum integer?
----@field new_lnum integer?
----@field source_lnum integer?
----@field hunk_index integer
-
----@class diffs.DiffHunk
----@field index integer
----@field file string?
----@field path string?
----@field old_range diffs.DiffHunkRange
----@field new_range diffs.DiffHunkRange
----@field buffer_range diffs.DiffHunkRange
----@field file_header_range diffs.DiffHunkRange?
----@field file_header_lines string[]
----@field header string
----@field generated_key string?
----@field section string?
----@field section_label string?
----@field diff_spec diffs.DiffSpec?
----@field edge { left: diffs.Endpoint, right: diffs.Endpoint, mutation_target: "index"|"worktree"|nil }?
----@field can_put boolean
----@field can_obtain boolean
----@field actionable boolean
----@field mutation_target "index"|"worktree"|nil
----@field source_lnum integer
----@field lines diffs.DiffHunkLine[]
-
----@param value integer
----@return integer
-local function source_lnum(value)
- return math.max(value, 1)
-end
-
----@param start integer
----@param count integer
----@return diffs.DiffHunkRange
-local function range(start, count)
- return {
- start = start,
- count = count,
- finish = count > 0 and (start + count - 1) or start,
- }
-end
-
----@param text string
----@return integer
-local function parse_count(text)
- if text == '' then
- return 1
- end
- return tonumber(text) or 1
-end
-
----@param line string
----@return diffs.DiffHunkRange?, diffs.DiffHunkRange?
-local function parse_header_ranges(line)
- local old_start, old_count, new_start, new_count =
- line:match('^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@')
- if not old_start then
- return nil, nil
- end
-
- return range(tonumber(old_start) or 0, parse_count(old_count)),
- range(tonumber(new_start) or 0, parse_count(new_count))
-end
-
----@param path string?
----@param strip_prefix? boolean
----@return string?
-local function normalize_path(path, strip_prefix)
- if not path or path == '/dev/null' then
- return nil
- end
- path = path:gsub('\t.*$', '')
- local normalized = strip_prefix and path:gsub('^%a/', '') or path
- return normalized
-end
-
----@param line string
----@return string?, string?
-local function parse_diff_git_paths(line)
- local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$')
- return normalize_path(old_path), normalize_path(new_path)
-end
-
----@param line string
----@return string?
-local function parse_old_path(line)
- return normalize_path(line:match('^%-%-%- (.+)$'), true)
-end
-
----@param line string
----@return string?
-local function parse_new_path(line)
- return normalize_path(line:match('^%+%+%+ (.+)$'), true)
-end
-
----@param line string
----@return boolean
-local function is_review_section_header(line)
- return line:match('^# Branch:') ~= nil
- or line:match('^# Staged:') ~= nil
- or line:match('^# Unstaged:') ~= nil
- or line:match('^# Untracked:') ~= nil
-end
-
----@param diff_spec diffs.DiffSpec?
----@return diffs.DiffSpec?
-local function normalize_spec(diff_spec)
- if not diff_spec then
- return nil
- end
- return diffspec.new(diff_spec)
-end
-
----@param lines string[]
----@return string[]
-local function copy_lines(lines)
- local copied = {}
- for _, line in ipairs(lines) do
- copied[#copied + 1] = line
- end
- return copied
-end
-
----@param diff_lines string[]
----@param diff_spec? diffs.DiffSpec
----@return diffs.DiffHunk[]
-function M.parse(diff_lines, diff_spec)
- local spec = normalize_spec(diff_spec)
- local hunks = {}
- local current_old_path = spec and spec.scope.kind == diffspec.scope_kind.file and spec.scope.path
- or nil
- local current_new_path = current_old_path
- local current_file = current_new_path or current_old_path
- local current_hunk = nil
- local current_file_header_start = nil
- local current_file_header_lines = {}
- local old_lnum = 0
- local new_lnum = 0
-
- ---@param finish_lnum integer
- local function finish_hunk(finish_lnum)
- if not current_hunk then
- return
- end
- current_hunk.buffer_range.finish = finish_lnum
- current_hunk.buffer_range.count = finish_lnum - current_hunk.buffer_range.start + 1
- current_hunk = nil
- end
-
- ---@param lnum integer
- ---@param line string
- local function start_file_header(lnum, line)
- current_file_header_start = lnum
- current_file_header_lines = { line }
- end
-
- ---@param lnum integer
- ---@param line string
- local function add_file_header_line(lnum, line)
- if not current_file_header_start then
- current_file_header_start = lnum
- end
- current_file_header_lines[#current_file_header_lines + 1] = line
- end
-
- ---@param hunk diffs.DiffHunk
- ---@param line diffs.DiffHunkLine
- local function add_line(hunk, line)
- hunk.lines[#hunk.lines + 1] = line
- end
-
- for lnum, line in ipairs(diff_lines) do
- if is_review_section_header(line) then
- finish_hunk(lnum - 1)
- current_old_path = spec and spec.scope.kind == diffspec.scope_kind.file and spec.scope.path
- or nil
- current_new_path = current_old_path
- current_file = current_new_path or current_old_path
- current_hunk = nil
- current_file_header_start = nil
- current_file_header_lines = {}
- old_lnum = 0
- new_lnum = 0
- elseif line:match('^diff %-%-git ') then
- finish_hunk(lnum - 1)
- current_old_path, current_new_path = parse_diff_git_paths(line)
- current_file = current_new_path or current_old_path
- start_file_header(lnum, line)
- elseif line:match('^%-%-%- ') and not current_hunk then
- current_old_path = parse_old_path(line) or current_old_path
- current_file = current_new_path or current_old_path
- add_file_header_line(lnum, line)
- elseif line:match('^%+%+%+ ') and not current_hunk then
- current_new_path = parse_new_path(line) or current_new_path
- current_file = current_new_path or current_old_path
- add_file_header_line(lnum, line)
- else
- local old_range, new_range = parse_header_ranges(line)
- if old_range and new_range then
- finish_hunk(lnum - 1)
- local index = #hunks + 1
- local file_header_lines = copy_lines(current_file_header_lines)
- current_hunk = {
- index = index,
- file = current_file,
- path = current_file,
- old_range = old_range,
- new_range = new_range,
- buffer_range = range(lnum, 1),
- file_header_range = current_file_header_start
- and range(current_file_header_start, #file_header_lines)
- or nil,
- file_header_lines = file_header_lines,
- header = line,
- source_lnum = source_lnum(new_range.start),
- lines = {},
- }
- old_lnum = old_range.start
- new_lnum = new_range.start
- add_line(current_hunk, {
- lnum = lnum,
- kind = 'header',
- text = line,
- file = current_file,
- old_lnum = old_range.start,
- new_lnum = new_range.start,
- source_lnum = source_lnum(new_range.start),
- hunk_index = index,
- })
- hunks[#hunks + 1] = current_hunk
- elseif current_hunk then
- local prefix = line:sub(1, 1)
- if prefix == '+' then
- add_line(current_hunk, {
- lnum = lnum,
- kind = 'add',
- text = line,
- file = current_file,
- new_lnum = new_lnum,
- source_lnum = source_lnum(new_lnum),
- hunk_index = current_hunk.index,
- })
- new_lnum = new_lnum + 1
- elseif prefix == '-' then
- add_line(current_hunk, {
- lnum = lnum,
- kind = 'delete',
- text = line,
- file = current_file,
- old_lnum = old_lnum,
- source_lnum = source_lnum(new_lnum),
- hunk_index = current_hunk.index,
- })
- old_lnum = old_lnum + 1
- elseif prefix == ' ' then
- add_line(current_hunk, {
- lnum = lnum,
- kind = 'context',
- text = line,
- file = current_file,
- old_lnum = old_lnum,
- new_lnum = new_lnum,
- source_lnum = source_lnum(new_lnum),
- hunk_index = current_hunk.index,
- })
- old_lnum = old_lnum + 1
- new_lnum = new_lnum + 1
- else
- add_line(current_hunk, {
- lnum = lnum,
- kind = 'meta',
- text = line,
- file = current_file,
- source_lnum = source_lnum(new_lnum),
- hunk_index = current_hunk.index,
- })
- end
- elseif current_file_header_start then
- add_file_header_line(lnum, line)
- end
- end
- end
-
- finish_hunk(#diff_lines)
-
- return M.decorate_actionability(hunks, spec)
-end
-
----@param hunk diffs.DiffHunk
----@param spec diffs.DiffSpec?
----@param target "index"|"worktree"|nil
----@param can_put boolean
----@param can_obtain boolean
-local function decorate_hunk_actionability(hunk, spec, target, can_put, can_obtain)
- hunk.diff_spec = spec
- hunk.edge = spec
- and {
- left = diffspec.endpoint(spec.left),
- right = diffspec.endpoint(spec.right),
- mutation_target = target,
- }
- or nil
- hunk.can_put = can_put
- hunk.can_obtain = can_obtain
- hunk.actionable = can_put or can_obtain
- hunk.mutation_target = target
-end
-
----@param hunks diffs.DiffHunk[]
----@param diff_spec? diffs.DiffSpec
----@return diffs.DiffHunk[]
-function M.decorate_actionability(hunks, diff_spec)
- local spec = normalize_spec(diff_spec)
- local target = spec and diffspec.mutation_target(spec) or nil
- local patch_actions = spec and diffspec.patch_actions(spec) or nil
- local can_put = patch_actions and patch_actions.can_put or false
- local can_obtain = patch_actions and patch_actions.can_obtain or false
-
- for _, hunk in ipairs(hunks or {}) do
- decorate_hunk_actionability(hunk, spec, target, can_put, can_obtain)
- end
- return hunks
-end
-
----@param hunks diffs.DiffHunk[]?
----@param lnum integer
----@return diffs.DiffHunk?
-function M.hunk_at_line(hunks, lnum)
- for _, hunk in ipairs(hunks or {}) do
- if lnum >= hunk.buffer_range.start and lnum <= hunk.buffer_range.finish then
- return hunk
- end
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]?
----@param lnum integer
----@return diffs.DiffHunkLine?
-function M.line_at(hunks, lnum)
- local hunk = M.hunk_at_line(hunks, lnum)
- if not hunk then
- return nil
- end
- for _, line in ipairs(hunk.lines) do
- if line.lnum == lnum then
- return line
- end
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]?
----@param lnum integer
----@return diffs.DiffHunk?
-function M.next_hunk(hunks, lnum)
- for _, hunk in ipairs(hunks or {}) do
- if hunk.buffer_range.start > lnum then
- return hunk
- end
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]?
----@param lnum integer
----@return diffs.DiffHunk?
-function M.prev_hunk(hunks, lnum)
- for i = #(hunks or {}), 1, -1 do
- local hunk = hunks[i]
- if hunk.buffer_range.start < lnum then
- return hunk
- end
- end
- return nil
-end
-
----@class diffs.SourcePosition
----@field side "left"|"right"
----@field lnum integer
-
----@param line diffs.DiffHunkLine?
----@return diffs.SourcePosition?
-function M.source_position_for(line)
- if not line then
- return nil
- end
- if line.kind == 'delete' and line.old_lnum then
- return { side = 'left', lnum = line.old_lnum }
- end
- if (line.kind == 'add' or line.kind == 'context') and line.new_lnum then
- return { side = 'right', lnum = line.new_lnum }
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]?
----@param selection { file: string, key?: string }
----@param position diffs.SourcePosition
----@return diffs.DiffHunkLine?
-function M.line_at_source(hunks, selection, position)
- local field = position.side == 'left' and 'old_lnum' or 'new_lnum'
- local kind = position.side == 'left' and 'delete' or 'add'
- for _, hunk in ipairs(hunks or {}) do
- local matches = selection.key and hunk.generated_key == selection.key
- or (not selection.key and (hunk.file or hunk.path) == selection.file)
- if matches then
- for _, line in ipairs(hunk.lines) do
- if (line.kind == kind or line.kind == 'context') and line[field] == position.lnum then
- return line
- end
- end
- end
- end
- return nil
-end
-
----@param item diffs.DiffHunk|diffs.DiffHunkLine|nil
----@return { path: string, lnum: integer }?
-function M.source_line_for(item)
- if not item then
- return nil
- end
- local path = item.file or item.path
- local lnum = item.source_lnum
- if not lnum and item.new_range then
- lnum = source_lnum(item.new_range.start)
- end
- if not path or not lnum then
- return nil
- end
- return { path = path, lnum = lnum }
-end
-
----@param bufnr integer
----@return diffs.DiffHunk[]
-local function buffer_hunks(bufnr)
- local ok, parsed = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_hunks')
- if ok and type(parsed) == 'table' then
- return parsed
- end
- return {}
-end
-
----@param bufnr integer
----@param name string
----@return any
-local function get_buf_var(bufnr, name)
- local ok, value = pcall(vim.api.nvim_buf_get_var, bufnr, name)
- if ok then
- return value
- end
- return nil
-end
-
----@param filepath string
----@return integer?
-local function find_window_for_file(filepath)
- local tabpage = vim.api.nvim_get_current_tabpage()
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do
- if vim.api.nvim_win_is_valid(win) then
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_buf_get_name(buf) == filepath then
- return win
- end
- end
- end
- return nil
-end
-
----@param repo_root string
----@param path string
----@return string
-local function resolve_worktree_path(repo_root, path)
- if path:sub(1, 1) == '/' then
- return path
- end
- return vim.fs.joinpath(repo_root, path)
-end
-
----@param bufnr integer
----@return { path: string, lnum: integer }?, string?
-function M.source_at_cursor(bufnr)
- local parsed = buffer_hunks(bufnr)
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
- local hunk = M.hunk_at_line(parsed, cursor_line)
- if not hunk then
- return nil, 'cursor is not on a diff hunk'
- end
-
- local spec = hunk.diff_spec and diffspec.new(hunk.diff_spec) or nil
- if not spec then
- return nil, 'missing diff specification for hunk'
- end
-
- if spec.right.kind == diffspec.endpoint_kind.index then
- return nil, 'cannot open index-backed diff hunk as a worktree file'
- end
- if spec.right.kind ~= diffspec.endpoint_kind.worktree then
- return nil, 'cannot open read-only tree-backed diff hunk as a worktree file'
- end
-
- local source = M.source_line_for(M.line_at(parsed, cursor_line)) or M.source_line_for(hunk)
- if not source then
- return nil, 'could not resolve source location for hunk'
- end
-
- local metadata = get_buf_var(bufnr, 'diffs_source')
- local repo_root = metadata and metadata.repo_root
- if type(repo_root) ~= 'string' or repo_root == '' then
- return nil, 'cannot open source without a source repository'
- end
-
- return {
- path = resolve_worktree_path(repo_root, source.path),
- lnum = source.lnum,
- },
- nil
-end
-
----@param bufnr integer
----@return boolean
-function M.open_source(bufnr)
- local source, err = M.source_at_cursor(bufnr)
- if not source then
- notify(err, vim.log.levels.WARN)
- return false
- end
-
- local win = find_window_for_file(source.path)
- if win then
- vim.api.nvim_set_current_win(win)
- else
- vim.cmd.edit(vim.fn.fnameescape(source.path))
- end
-
- local line_count = vim.api.nvim_buf_line_count(0)
- local lnum = math.max(1, math.min(source.lnum, line_count))
- vim.api.nvim_win_set_cursor(0, { lnum, 0 })
- return true
-end
-
----@param bufnr integer
-function M.goto_next(bufnr)
- local parsed = buffer_hunks(bufnr)
- if #parsed == 0 then
- return
- end
-
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
- local next_hunk = M.next_hunk(parsed, cursor_line) or parsed[1]
- if next_hunk == parsed[1] and next_hunk.buffer_range.start <= cursor_line then
- notify('wrapped to first hunk', vim.log.levels.INFO)
- end
- vim.api.nvim_win_set_cursor(0, { next_hunk.buffer_range.start, 0 })
-end
-
----@param bufnr integer
-function M.goto_prev(bufnr)
- local parsed = buffer_hunks(bufnr)
- if #parsed == 0 then
- return
- end
-
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
- local prev_hunk = M.prev_hunk(parsed, cursor_line) or parsed[#parsed]
- if prev_hunk == parsed[#parsed] and prev_hunk.buffer_range.start >= cursor_line then
- notify('wrapped to last hunk', vim.log.levels.INFO)
- end
- vim.api.nvim_win_set_cursor(0, { prev_hunk.buffer_range.start, 0 })
-end
-
-return M
diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua
index 200df46a..d4cbb265 100644
--- a/lua/diffs/init.lua
+++ b/lua/diffs/init.lua
@@ -1,33 +1,11 @@
----@class diffs.PublicApi
----@field attach fun(bufnr?: integer)
----@field open_review fun(spec?: diffs.ReviewSpec, opts?: diffs.ReviewOpts): integer?
----@field refresh fun(bufnr?: integer)
----@field review_files fun(bufnr?: integer): diffs.ReviewFile[]?
----@field review_current fun(bufnr?: integer): { index: integer, count: integer, file: diffs.ReviewFile }?
----@field review_goto fun(target: string, bufnr?: integer): boolean
----@field review_next_file fun()
----@field review_prev_file fun()
----@field review_toggle_layout fun(opts?: diffs.ReviewToggleLayoutOpts): integer?
----@field select_review_file fun(bufnr?: integer)
-
local runtime = require('diffs.runtime')
-local function command(name)
- return function(...)
- return require('diffs.commands')[name](...)
- end
-end
-
----@type diffs.PublicApi
return {
attach = runtime.attach,
- open_review = command('open_review'),
+ detach = runtime.detach,
refresh = runtime.refresh,
- review_files = command('review_files'),
- review_current = command('review_current'),
- review_goto = command('review_goto'),
- review_next_file = command('review_next_file'),
- review_prev_file = command('review_prev_file'),
- review_toggle_layout = command('review_toggle_layout'),
- select_review_file = command('select_review_file'),
+ highlight_options = runtime.get_highlight_opts,
+ diff_options = require('diffs.diffopt').resolve,
+ compute_intra_hunks = require('diffs.diff').compute_intra_hunks,
+ whitespace_only_lines = require('diffs.diff').whitespace_only_lines,
}
diff --git a/lua/diffs/keymaps.lua b/lua/diffs/keymaps.lua
deleted file mode 100644
index 43c0bea8..00000000
--- a/lua/diffs/keymaps.lua
+++ /dev/null
@@ -1,86 +0,0 @@
-local M = {}
-
----@class diffs.BufferKeymap
----@field rhs string?
----@field callback function?
-
----@param bufnr integer
----@param lhs string
----@return table?
-local function get_buffer_keymap(bufnr, lhs)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do
- if keymap.lhs == lhs then
- return keymap
- end
- end
-end
-
----@param keymap table
----@return diffs.BufferKeymap
-local function keymap_identity(keymap)
- if keymap.callback then
- return { callback = keymap.callback }
- end
- return { rhs = keymap.rhs or '' }
-end
-
----@param keymap table?
----@param identity diffs.BufferKeymap?
----@return boolean
-local function keymap_matches(keymap, identity)
- if not keymap or not identity then
- return false
- end
- if identity.callback then
- return keymap.callback == identity.callback
- end
- return (keymap.rhs or '') == identity.rhs
-end
-
----@param registry table>
----@param bufnr integer
-function M.clear_buffer_keymaps(registry, bufnr)
- local registered = registry[bufnr]
- if not registered then
- return
- end
- if not vim.api.nvim_buf_is_valid(bufnr) then
- registry[bufnr] = nil
- return
- end
- for lhs, identity in pairs(registered) do
- local current = get_buffer_keymap(bufnr, lhs)
- if keymap_matches(current, identity) then
- pcall(vim.keymap.del, 'n', lhs, { buffer = bufnr })
- end
- end
- registry[bufnr] = nil
-end
-
----@param registry table>
----@param bufnr integer
----@param maps [string|false, string|function, table?][]
-function M.set_buffer_keymaps(registry, bufnr, maps)
- M.clear_buffer_keymaps(registry, bufnr)
-
- local installed = {}
- for _, map in ipairs(maps) do
- local lhs = map[1]
- if type(lhs) == 'string' and lhs ~= '' then
- local current = get_buffer_keymap(bufnr, lhs)
- if not current or keymap_matches(current, installed[lhs]) then
- local opts = vim.tbl_extend('force', { buffer = bufnr }, map[3] or {})
- vim.keymap.set('n', lhs, map[2], opts)
- local installed_keymap = get_buffer_keymap(bufnr, lhs)
- if installed_keymap then
- installed[lhs] = keymap_identity(installed_keymap)
- end
- end
- end
- end
- if next(installed) then
- registry[bufnr] = installed
- end
-end
-
-return M
diff --git a/lua/diffs/lists.lua b/lua/diffs/lists.lua
deleted file mode 100644
index 2a70a91c..00000000
--- a/lua/diffs/lists.lua
+++ /dev/null
@@ -1,999 +0,0 @@
-local M = {}
-
-local generated = require('diffs.generated')
-local hunk_model = require('diffs.hunks')
-
-local generated_list_autocmds = {}
-local generated_list_state = {}
-local quickfix_keymap_autocmd
-local quickfix_enter_desc = 'Open quickfix item'
-local quickfix_ns = vim.api.nvim_create_namespace('diffs_quickfix')
-
-local group = vim.api.nvim_create_augroup('diffs_generated_lists', { clear = false })
-
----@class diffs.GeneratedListOptions
----@field title? string
----@field loclist_title? string
----@field diff_spec? diffs.DiffSpec
----@field metadata_for_line? fun(lnum: integer, file: string): table?
----@field sections? table[]
----@field store_hunks? boolean
----@field quickfix? boolean
----@field is_skipped? fun(entry: table): boolean
-
----@param bufnr integer
----@param name string
----@return any
-local function get_buf_var(bufnr, name)
- return generated.get_var(bufnr, name)
-end
-
----@param bufnr integer
----@return diffs.DiffSpec?
-local function buffer_diff_spec(bufnr)
- local source = get_buf_var(bufnr, 'diffs_source')
- return source and source.spec
-end
-
----@param bufnr integer
----@return integer[]
-local function windows_for_buffer(bufnr)
- local wins = {}
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then
- wins[#wins + 1] = win
- end
- end
- return wins
-end
-
----@param win integer
----@return integer?
-local function win_number(win)
- local nr = vim.fn.win_id2win(win)
- if nr > 0 then
- return nr
- end
- return nil
-end
-
----@param hunk diffs.DiffHunk
----@return string
-local function hunk_file(hunk)
- return hunk.file or hunk.path or '(unknown)'
-end
-
----@param hunk diffs.DiffHunk
----@return integer
-local function file_lnum(hunk)
- return hunk.file_header_range and hunk.file_header_range.start or hunk.buffer_range.start
-end
-
----@param line string
----@return string?
-local function diff_file(line)
- local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$')
- return new_path or old_path
-end
-
----@param hunk diffs.DiffHunk
----@return integer, integer
-local function hunk_stats(hunk)
- local adds = 0
- local dels = 0
- for _, line in ipairs(hunk.lines or {}) do
- if line.kind == 'add' then
- adds = adds + 1
- elseif line.kind == 'delete' then
- dels = dels + 1
- end
- end
- return adds, dels
-end
-
----@param entries table[]
----@param by_file table
----@param file string
----@param lnum integer
----@param meta? table
----@return table
-local function ensure_file_entry(entries, by_file, file, lnum, meta)
- meta = meta or {}
- local key = meta.key or file
- local entry = by_file[key]
- if entry then
- entry.lnum = math.min(entry.lnum, lnum)
- entry.skipped = entry.skipped or meta.skipped == true
- return entry
- end
-
- entry = {
- key = key,
- file = file,
- lnum = lnum,
- adds = 0,
- dels = 0,
- hunks = {},
- section = meta.section,
- section_label = meta.section_label,
- diff_spec = meta.diff_spec,
- skipped = meta.skipped == true,
- }
- by_file[key] = entry
- entries[#entries + 1] = entry
- return entry
-end
-
----@param opts? table
----@param lnum integer
----@param file string
----@return table
-local function metadata_for_line(opts, lnum, file)
- local fn = opts and opts.metadata_for_line
- if type(fn) ~= 'function' then
- return {}
- end
-
- local meta = fn(lnum, file)
- if type(meta) == 'table' then
- return meta
- end
- return {}
-end
-
----@param hunk diffs.DiffHunk?
----@return string?
-local function hunk_key(hunk)
- if type(hunk) ~= 'table' then
- return nil
- end
- return hunk.generated_key
-end
-
----@param hunks diffs.DiffHunk[]
----@param diff_lines? string[]
----@param opts? table
----@return table[]
-local function file_entries(hunks, diff_lines, opts)
- local by_file = {}
- local entries = {}
- for lnum, line in ipairs(diff_lines or {}) do
- local file = diff_file(line)
- if file then
- ensure_file_entry(entries, by_file, file, lnum, metadata_for_line(opts, lnum, file))
- end
- end
-
- for _, hunk in ipairs(hunks) do
- local file = hunk_file(hunk)
- local meta = metadata_for_line(opts, hunk.buffer_range.start, file)
- local entry = ensure_file_entry(entries, by_file, file, file_lnum(hunk), meta)
- local adds, dels = hunk_stats(hunk)
- entry.adds = entry.adds + adds
- entry.dels = entry.dels + dels
- entry.hunks[#entry.hunks + 1] = hunk
- hunk.generated_key = entry.key
- hunk.section = entry.section
- hunk.section_label = entry.section_label
- if not hunk.diff_spec and entry.diff_spec then
- hunk_model.decorate_actionability({ hunk }, entry.diff_spec)
- end
- end
- return entries
-end
-
----@param entries table[]
----@param opts? table
-local function apply_skipped(entries, opts)
- local is_skipped = opts and opts.is_skipped
- if type(is_skipped) ~= 'function' then
- return
- end
- for _, entry in ipairs(entries) do
- entry.skipped = is_skipped(entry) == true
- end
-end
-
----@param entries table[]
-local function format_file_text(entries)
- local max_fname, max_section = 0, 0
- local max_add, max_del = 0, 0
- for _, entry in ipairs(entries) do
- entry.display_file = entry.skipped and (entry.file .. ' (skipped)') or entry.file
- max_fname = math.max(max_fname, vim.fn.strdisplaywidth(entry.display_file))
- max_section = math.max(max_section, entry.section_label and #entry.section_label + 2 or 0)
- if entry.adds > 0 then
- max_add = math.max(max_add, #tostring(entry.adds) + 1)
- end
- if entry.dels > 0 then
- max_del = math.max(max_del, #tostring(entry.dels) + 1)
- end
- end
-
- for _, entry in ipairs(entries) do
- entry.display_file = entry.display_file
- .. string.rep(' ', max_fname - vim.fn.strdisplaywidth(entry.display_file))
- local parts = {}
- if max_section > 0 then
- local section = entry.section_label and ('[' .. entry.section_label .. ']') or ''
- parts[#parts + 1] = section .. string.rep(' ', max_section - #section)
- end
- if max_add > 0 then
- parts[#parts + 1] = entry.adds > 0 and string.format('%' .. max_add .. 's', '+' .. entry.adds)
- or string.rep(' ', max_add)
- end
- if max_del > 0 then
- parts[#parts + 1] = entry.dels > 0 and string.format('%' .. max_del .. 's', '-' .. entry.dels)
- or string.rep(' ', max_del)
- end
- entry.text = table.concat(parts, ' '):gsub('%s+$', '')
- end
-end
-
----@param bufnr integer
----@param entries table[]
----@return table[]
-local function quickfix_items(bufnr, entries)
- format_file_text(entries)
- local items = {}
- for _, entry in ipairs(entries) do
- items[#items + 1] = {
- bufnr = bufnr,
- lnum = entry.lnum,
- col = 1,
- module = entry.display_file,
- text = entry.text,
- user_data = {
- diffs = {
- kind = 'file',
- file = entry.file,
- key = entry.key,
- section = entry.section,
- section_label = entry.section_label,
- diff_spec = entry.diff_spec,
- skipped = entry.skipped == true,
- },
- },
- }
- end
- return items
-end
-
----@param entries table[]
----@param lnum integer
----@param sections? table[]
----@return table?
-local function entry_at_lnum(entries, lnum, sections)
- if #entries == 0 then
- return nil
- end
-
- local section = nil
- for _, candidate in ipairs(sections or {}) do
- if lnum >= candidate.start and lnum <= candidate.finish then
- section = candidate
- break
- end
- end
-
- local selected = nil
- for _, entry in ipairs(entries) do
- if section and entry.section ~= section.id then
- goto continue
- end
- if lnum >= entry.lnum then
- selected = entry
- else
- break
- end
- ::continue::
- end
- if selected then
- return selected
- end
-
- if section then
- for _, entry in ipairs(entries) do
- if entry.section == section.id then
- return entry
- end
- end
- end
- return entries[1]
-end
-
----@param hunks diffs.DiffHunk[]
----@param entries table[]
----@param lnum integer
----@return string?
-local function file_at_lnum(hunks, entries, lnum)
- local entry = entry_at_lnum(entries or file_entries(hunks), lnum)
- return entry and entry.file or nil
-end
-
----@param bufnr integer
----@param hunks diffs.DiffHunk[]
----@param entry table?
----@return table[]
-local function loclist_items(bufnr, hunks, entry)
- local file_hunk_count = {}
- local key = entry and entry.key or nil
- local file = entry and entry.file or nil
- local items = {}
- for _, hunk in ipairs(hunks) do
- local hfile = hunk_file(hunk)
- local hkey = hunk_key(hunk)
- if (key and hkey == key) or (not key and (not file or hfile == file)) then
- local count_key = hkey or hfile
- file_hunk_count[count_key] = (file_hunk_count[count_key] or 0) + 1
- local display = hunk.section_label and ('[' .. hunk.section_label .. '] ' .. hfile) or hfile
- items[#items + 1] = {
- bufnr = bufnr,
- lnum = hunk.buffer_range.start,
- col = 1,
- module = hfile,
- text = ('%s (hunk %d) %s'):format(display, file_hunk_count[count_key], hunk.header),
- user_data = {
- diffs = {
- kind = 'hunk',
- file = hfile,
- key = hkey,
- section = hunk.section,
- section_label = hunk.section_label,
- diff_spec = hunk.diff_spec,
- hunk = hunk.index,
- },
- },
- }
- end
- end
- return items
-end
-
----@param hunks diffs.DiffHunk[]
----@param lnum integer
----@return diffs.DiffHunk?
-local function hunk_at_lnum(hunks, lnum)
- for _, hunk in ipairs(hunks) do
- if lnum >= hunk.buffer_range.start and lnum <= hunk.buffer_range.finish then
- return hunk
- end
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]
----@param index integer?
----@return diffs.DiffHunk?
-local function hunk_by_index(hunks, index)
- if type(index) ~= 'number' then
- return nil
- end
- for _, hunk in ipairs(hunks) do
- if hunk.index == index then
- return hunk
- end
- end
- return nil
-end
-
----@param hunks diffs.DiffHunk[]
----@param target diffs.DiffHunk?
----@return integer?
-local function file_hunk_index(hunks, target)
- if not target then
- return nil
- end
-
- local count = 0
- local file = hunk_file(target)
- local key = hunk_key(target)
- for _, hunk in ipairs(hunks) do
- if (key and hunk_key(hunk) == key) or (not key and hunk_file(hunk) == file) then
- count = count + 1
- end
- if hunk == target then
- return count
- end
- end
- return nil
-end
-
----@param state table
----@param entry table
----@return diffs.GeneratedFileSelection
-local function selection_from_entry(state, entry)
- local hunk = entry.hunks[1]
- return {
- bufnr = 0,
- file = entry.file,
- key = (hunk and hunk_key(hunk)) or entry.key,
- section = (hunk and hunk.section) or entry.section,
- section_label = (hunk and hunk.section_label) or entry.section_label,
- diff_spec = (hunk and hunk.diff_spec) or entry.diff_spec,
- lnum = entry.lnum,
- hunk = hunk,
- hunk_index = file_hunk_index(state.hunks, hunk),
- added = entry.adds,
- removed = entry.dels,
- skipped = entry.skipped == true,
- }
-end
-
----@param item table?
----@return table?
-local function item_diffs_data(item)
- if type(item) ~= 'table' or type(item.user_data) ~= 'table' then
- return nil
- end
-
- local diffs = item.user_data.diffs
- if type(diffs) == 'table' then
- return diffs
- end
- return nil
-end
-
----@param item table?
----@return table?, string?
-local function selection_from_item(item)
- local data = item_diffs_data(item)
- if not data or (data.kind ~= 'file' and data.kind ~= 'hunk') then
- return nil, 'quickfix item is not a generated diff file'
- end
- if type(item.bufnr) ~= 'number' or item.bufnr <= 0 then
- return nil, 'quickfix item is missing a generated buffer'
- end
- if type(data.file) ~= 'string' or data.file == '' then
- return nil, 'quickfix item is missing a generated diff file'
- end
-
- local state = generated_list_state[item.bufnr]
- local hunk = state and hunk_by_index(state.hunks, data.hunk) or nil
- local key = data.key or hunk_key(hunk) or data.file
- return {
- bufnr = item.bufnr,
- file = data.file,
- key = key,
- section = data.section,
- section_label = data.section_label,
- diff_spec = data.diff_spec,
- lnum = item.lnum,
- hunk = hunk,
- hunk_index = state and file_hunk_index(state.hunks, hunk) or nil,
- skipped = data.skipped == true,
- },
- nil
-end
-
----@param title string
----@param items table[]
-local function set_quickfix(title, items)
- M.ensure_quickfix_keymap()
- vim.fn.setqflist({}, ' ', {
- title = title,
- items = items,
- quickfixtextfunc = 'v:lua._diffs_qftf',
- })
-end
-
----@param win integer
----@param title string
----@param items table[]
-local function set_loclist(win, title, items)
- local nr = win_number(win)
- if not nr then
- return
- end
- M.ensure_quickfix_keymap()
- vim.fn.setloclist(nr, {}, ' ', {
- title = title,
- items = items,
- quickfixtextfunc = 'v:lua._diffs_qftf',
- })
-end
-
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
----@return { hunks: diffs.DiffHunk[], entries: table[], sections: table[]?, loclist_title: string }
-local function list_state(diff_lines, opts)
- opts = opts or {}
- local diff_spec = opts.diff_spec
- local hunks = hunk_model.parse(diff_lines, diff_spec)
- local entries = file_entries(hunks, diff_lines, opts)
- apply_skipped(entries, opts)
- local title = opts.title or 'diffs'
- return {
- hunks = hunks,
- entries = entries,
- sections = opts.sections,
- loclist_title = opts.loclist_title or (title .. ' hunks'),
- }
-end
-
----@param bufnr integer
----@param win integer
----@param force? boolean
-local function refresh_loclist_for_window(bufnr, win, force)
- local state = generated_list_state[bufnr]
- if not state or not vim.api.nvim_win_is_valid(win) then
- return
- end
- if vim.api.nvim_win_get_buf(win) ~= bufnr then
- return
- end
-
- local cursor = vim.api.nvim_win_get_cursor(win)[1]
- local entry = entry_at_lnum(state.entries, cursor, state.sections)
- local key = entry and entry.key or nil
- local cache = state.win_files or {}
- state.win_files = cache
- if not force and cache[win] == key then
- return
- end
- cache[win] = key
-
- set_loclist(win, state.loclist_title, loclist_items(bufnr, state.hunks, entry))
-end
-
----@param bufnr integer
-local function refresh_current_loclist(bufnr)
- local win = vim.api.nvim_get_current_win()
- refresh_loclist_for_window(bufnr, win, false)
-end
-
----@param bufnr integer
-local function ensure_generated_autocmds(bufnr)
- if generated_list_autocmds[bufnr] then
- return
- end
-
- local ids = {}
- ids[#ids + 1] = vim.api.nvim_create_autocmd({ 'BufEnter', 'CursorMoved' }, {
- group = group,
- buffer = bufnr,
- callback = function(args)
- refresh_current_loclist(args.buf)
- end,
- })
- ids[#ids + 1] = vim.api.nvim_create_autocmd('BufWipeout', {
- group = group,
- buffer = bufnr,
- once = true,
- callback = function(args)
- generated_list_state[args.buf] = nil
- generated_list_autocmds[args.buf] = nil
- end,
- })
- generated_list_autocmds[bufnr] = ids
-end
-
----@param item table?
-local function sync_split_item(item)
- if type(item) ~= 'table' or type(item.bufnr) ~= 'number' then
- return
- end
-
- local source = get_buf_var(item.bufnr, 'diffs_source')
- if type(source) ~= 'table' or source.kind ~= 'split_endpoint' then
- return
- end
-
- require('diffs.split').sync_cursor_to_hunk(item.bufnr)
-end
-
----@param info table
----@return table[]
-local function quickfix_text_items(info)
- if info.quickfix == 1 then
- return vim.fn.getqflist({ id = info.id, items = 0 }).items
- end
- return vim.fn.getloclist(info.winid or 0, { id = info.id, items = 0 }).items
-end
-
----@return table?, boolean
-local function current_quickfix_item()
- local info = vim.fn.getwininfo(vim.api.nvim_get_current_win())[1]
- if type(info) ~= 'table' or info.quickfix ~= 1 then
- return nil, false
- end
-
- local is_loclist = info.loclist == 1
- local row = vim.api.nvim_win_get_cursor(0)[1]
- local items = is_loclist and vim.fn.getloclist(0, { items = 0 }).items
- or vim.fn.getqflist({ items = 0 }).items
- return items[row], is_loclist
-end
-
----@param bufnr integer
----@param explicit_lnum integer?
----@return integer
-local function selection_lnum(bufnr, explicit_lnum)
- if type(explicit_lnum) == 'number' then
- return explicit_lnum
- end
-
- if vim.api.nvim_get_current_buf() == bufnr then
- return vim.api.nvim_win_get_cursor(0)[1]
- end
-
- local win = windows_for_buffer(bufnr)[1]
- if win then
- return vim.api.nvim_win_get_cursor(win)[1]
- end
-
- return 1
-end
-
-local function jump_current_quickfix_item()
- local item, is_loclist = current_quickfix_item()
- local row = vim.api.nvim_win_get_cursor(0)[1]
- local command = is_loclist and 'll' or 'cc'
- local jumped = false
- if type(item) == 'table' and type(item.bufnr) == 'number' then
- local item_win = windows_for_buffer(item.bufnr)[1]
- if item_win then
- local lnum = math.max(1, math.min(item.lnum or 1, vim.api.nvim_buf_line_count(item.bufnr)))
- local col = math.max(0, (item.col or 1) - 1)
- vim.api.nvim_win_set_cursor(item_win, { lnum, col })
- vim.api.nvim_set_current_win(item_win)
- jumped = true
- end
- end
- if not jumped then
- if not is_loclist and require('diffs.commands').review_quickfix_jump(item) then
- vim.fn.setqflist({}, 'a', { idx = row })
- return
- end
- vim.cmd(command .. ' ' .. row)
- end
- sync_split_item(item)
-end
-
----@class diffs.GeneratedFileSelectionOpts
----@field bufnr? integer
----@field lnum? integer
----@field item? table
-
----@class diffs.GeneratedFileSelection
----@field bufnr integer
----@field file string
----@field key? string
----@field section? string
----@field section_label? string
----@field diff_spec? diffs.DiffSpec
----@field lnum integer?
----@field hunk? diffs.DiffHunk
----@field hunk_index? integer
----@field added? integer
----@field removed? integer
----@field skipped? boolean
-
----@param opts? diffs.GeneratedFileSelectionOpts
----@return diffs.GeneratedFileSelection?, string?
-function M.selected_generated_file(opts)
- opts = opts or {}
-
- if opts.item then
- return selection_from_item(opts.item)
- end
-
- if not opts.bufnr and not opts.lnum then
- local item = current_quickfix_item()
- if item then
- return selection_from_item(item)
- end
- end
-
- local bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
- local state = generated_list_state[bufnr]
- if not state then
- return nil, 'current buffer has no generated diff file index'
- end
-
- local lnum = selection_lnum(bufnr, opts.lnum)
- local entry = entry_at_lnum(state.entries, lnum, state.sections)
- if not entry then
- return nil, 'no generated diff file selected'
- end
-
- local hunk = hunk_at_lnum(state.hunks, lnum)
- return {
- bufnr = bufnr,
- file = entry.file,
- key = (hunk and hunk_key(hunk)) or entry.key,
- section = (hunk and hunk.section) or entry.section,
- section_label = (hunk and hunk.section_label) or entry.section_label,
- diff_spec = (hunk and hunk.diff_spec) or entry.diff_spec,
- lnum = lnum,
- hunk = hunk,
- hunk_index = file_hunk_index(state.hunks, hunk),
- skipped = entry.skipped == true,
- },
- nil
-end
-
----@param bufnr integer
----@return table?
-local function enter_keymap(bufnr)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do
- if keymap.lhs == '' then
- return keymap
- end
- end
- return nil
-end
-
----@param bufnr integer
-local highlight_quickfix = vim.schedule_wrap(function(bufnr)
- if not vim.api.nvim_buf_is_valid(bufnr) then
- return
- end
- vim.api.nvim_buf_clear_namespace(bufnr, quickfix_ns, 0, -1)
- local qf = vim.fn.getqflist({ qfbufnr = 0, items = 0 })
- if qf.qfbufnr ~= bufnr then
- return
- end
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- for row, item in ipairs(qf.items) do
- local data = item_diffs_data(item)
- if data and data.kind == 'file' and lines[row] then
- local offset = #lines[row] - #item.text
- for first, count, last in item.text:gmatch('()([+-]%d+)()') do
- vim.api.nvim_buf_set_extmark(bufnr, quickfix_ns, row - 1, offset + first - 1, {
- end_col = offset + last - 1,
- hl_group = count:sub(1, 1) == '+' and 'DiffsAddCount' or 'DiffsDeleteCount',
- })
- end
- end
- end
-end)
-
----@param bufnr integer
-local function set_quickfix_keymap(bufnr)
- local existing = enter_keymap(bufnr)
- if existing and existing.desc ~= quickfix_enter_desc then
- return
- end
- vim.keymap.set('n', '', jump_current_quickfix_item, {
- buffer = bufnr,
- desc = quickfix_enter_desc,
- })
-end
-
-function M.ensure_quickfix_keymap()
- if quickfix_keymap_autocmd then
- return
- end
-
- quickfix_keymap_autocmd = vim.api.nvim_create_autocmd('FileType', {
- group = group,
- pattern = 'qf',
- callback = function(args)
- set_quickfix_keymap(args.buf)
- highlight_quickfix(args.buf)
- end,
- })
-
- for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
- if
- vim.api.nvim_buf_is_valid(bufnr)
- and vim.api.nvim_get_option_value('filetype', { buf = bufnr }) == 'qf'
- then
- set_quickfix_keymap(bufnr)
- highlight_quickfix(bufnr)
- end
- end
-end
-
--- selene: allow(global_usage)
-function _G._diffs_qftf(info)
- local items = quickfix_text_items(info)
- local lines = {}
- for i = info.start_idx, info.end_idx do
- local e = items[i] or {}
- local data = item_diffs_data(e)
- lines[#lines + 1] = data
- and data.kind == 'file'
- and ('%s|| %s'):format(e.module or '', e.text or '')
- or ''
- end
- return lines
-end
-
----@param bufnr integer
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
-function M.set_for_unified_buffer(bufnr, diff_lines, opts)
- opts = opts or {}
- local title = opts.title or 'diffs'
- opts.diff_spec = opts.diff_spec or buffer_diff_spec(bufnr)
- local state = list_state(diff_lines, opts)
-
- if opts.store_hunks then
- generated.set_hunks(bufnr, state.hunks)
- end
-
- generated_list_state[bufnr] = {
- hunks = state.hunks,
- entries = state.entries,
- sections = state.sections,
- loclist_title = state.loclist_title,
- win_files = {},
- }
- ensure_generated_autocmds(bufnr)
-
- local want_quickfix = opts.quickfix
- if want_quickfix == nil then
- want_quickfix = #state.entries > 1
- end
- if want_quickfix then
- set_quickfix(title, quickfix_items(bufnr, state.entries))
- end
- for _, win in ipairs(windows_for_buffer(bufnr)) do
- refresh_loclist_for_window(bufnr, win, true)
- end
-end
-
----@param bufnr integer
----@return diffs.DiffHunk[]
-function M.generated_hunks(bufnr)
- local state = generated_list_state[bufnr]
- return state and state.hunks or {}
-end
-
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
----@return diffs.GeneratedFileSelection?
-function M.first_generated_file(diff_lines, opts)
- local state = list_state(diff_lines, opts)
- local entry = state.entries[1]
- if not entry then
- return nil
- end
- return selection_from_entry(state, entry)
-end
-
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
----@return diffs.GeneratedFileSelection[]
-function M.generated_files(diff_lines, opts)
- local state = list_state(diff_lines, opts)
- local files = {}
- for _, entry in ipairs(state.entries) do
- files[#files + 1] = selection_from_entry(state, entry)
- end
- return files
-end
-
----@param bufnr integer
----@param diff_lines string[]
----@param opts? diffs.GeneratedListOptions
-function M.set_loclist_for_buffer(bufnr, diff_lines, opts)
- opts = opts or {}
- local state = list_state(diff_lines, {
- diff_spec = opts.diff_spec or buffer_diff_spec(bufnr),
- title = opts.title,
- loclist_title = opts.loclist_title,
- })
- local title = state.loclist_title
- local entry = state.entries[1]
- for _, win in ipairs(windows_for_buffer(bufnr)) do
- set_loclist(win, title, loclist_items(bufnr, state.hunks, entry))
- end
-end
-
----@param hunk diffs.DiffHunk
----@param anchors integer[]?
----@return integer
-local function split_anchor_lnum(hunk, anchors)
- return (anchors and anchors[hunk.index]) or 1
-end
-
----@param hunk diffs.DiffHunk
----@param side "left"|"right"
----@param left_buf integer
----@param right_buf integer
----@return integer
-local function split_target_buf(hunk, side, left_buf, right_buf)
- local range = side == 'left' and hunk.old_range or hunk.new_range
- if range.count > 0 then
- return side == 'left' and left_buf or right_buf
- end
- return side == 'left' and right_buf or left_buf
-end
-
----@param left_buf integer
----@param right_buf integer
----@param hunks diffs.DiffHunk[]
----@param anchors integer[]?
----@param side "left"|"right"
----@return table[]
-local function split_loclist_items(left_buf, right_buf, hunks, anchors, side)
- local items = {}
- for i, hunk in ipairs(hunks) do
- items[#items + 1] = {
- bufnr = split_target_buf(hunk, side, left_buf, right_buf),
- lnum = split_anchor_lnum(hunk, anchors),
- col = 1,
- module = hunk_file(hunk),
- text = ('%s (hunk %d) %s'):format(hunk_file(hunk), i, hunk.header),
- user_data = {
- diffs = {
- kind = 'split_hunk',
- file = hunk_file(hunk),
- hunk = i,
- side = side,
- },
- },
- }
- end
- return items
-end
-
----@param opts { left_buf: integer, right_buf: integer, hunks: diffs.DiffHunk[], anchors: integer[]? }
----@return table[]
-local function split_quickfix_items(opts)
- local entries = file_entries(opts.hunks)
- format_file_text(entries)
- local items = {}
- for _, entry in ipairs(entries) do
- local first_hunk = entry.hunks[1]
- local lnum = entry.lnum
- local target_buf = opts.right_buf
- if first_hunk then
- lnum = split_anchor_lnum(first_hunk, opts.anchors)
- target_buf = split_target_buf(first_hunk, 'right', opts.left_buf, opts.right_buf)
- end
- items[#items + 1] = {
- bufnr = target_buf,
- lnum = lnum,
- col = 1,
- module = entry.display_file,
- text = entry.text,
- user_data = {
- diffs = {
- kind = 'file',
- file = entry.file,
- },
- },
- }
- end
- return items
-end
-
----@param opts { title: string, loclist_title?: string, left_buf: integer, right_buf: integer, left_win?: integer, right_win?: integer, hunks: diffs.DiffHunk[], anchors?: integer[], quickfix?: boolean }
-function M.set_for_split_pair(opts)
- local title = opts.title
- local loclist_title = opts.loclist_title or (title .. ' hunks')
-
- local quickfix_entries = split_quickfix_items(opts)
- if opts.quickfix ~= false and #quickfix_entries > 1 then
- set_quickfix(title, quickfix_entries)
- end
- for _, win in ipairs(opts.left_win and { opts.left_win } or windows_for_buffer(opts.left_buf)) do
- set_loclist(
- win,
- loclist_title,
- split_loclist_items(opts.left_buf, opts.right_buf, opts.hunks, opts.anchors, 'left')
- )
- end
- for _, win in ipairs(opts.right_win and { opts.right_win } or windows_for_buffer(opts.right_buf)) do
- set_loclist(
- win,
- loclist_title,
- split_loclist_items(opts.left_buf, opts.right_buf, opts.hunks, opts.anchors, 'right')
- )
- end
-end
-
-M._test = {
- file_at_lnum = file_at_lnum,
- file_entries = file_entries,
- hunk_at_lnum = hunk_at_lnum,
- loclist_items = loclist_items,
- quickfix_items = quickfix_items,
- selected_generated_file = M.selected_generated_file,
-}
-
-return M
diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua
deleted file mode 100644
index d61f1c4e..00000000
--- a/lua/diffs/merge.lua
+++ /dev/null
@@ -1,263 +0,0 @@
-local M = {}
-
-local conflict = require('diffs.conflict')
-local notify = require('diffs.log').notify
-local rails = require('diffs.rails')
-
----@type table>
-local resolved_hunks = {}
-
----@class diffs.MergeHunkInfo
----@field index integer
----@field start_line integer
----@field end_line integer
----@field del_lines string[]
----@field add_lines string[]
-
----@param bufnr integer
----@return diffs.MergeHunkInfo[]
-function M.parse_hunks(bufnr)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- local rail_width = rails.width_for_buffer(bufnr)
- local hunks = {}
- local current = nil
-
- for i, line in ipairs(lines) do
- line = rails.strip(line, rail_width)
- local idx = i - 1
- if line:match('^@@') then
- if current then
- current.end_line = idx - 1
- table.insert(hunks, current)
- end
- current = {
- index = #hunks + 1,
- start_line = idx,
- end_line = idx,
- del_lines = {},
- add_lines = {},
- }
- elseif current then
- local prefix = line:sub(1, 1)
- if prefix == '-' then
- table.insert(current.del_lines, line:sub(2))
- elseif prefix == '+' then
- table.insert(current.add_lines, line:sub(2))
- elseif prefix ~= ' ' and prefix ~= '\\' then
- current.end_line = idx - 1
- table.insert(hunks, current)
- current = nil
- end
- if current then
- current.end_line = idx
- end
- end
- end
-
- if current then
- table.insert(hunks, current)
- end
-
- return hunks
-end
-
----@param bufnr integer
----@return diffs.MergeHunkInfo?
-function M.find_hunk_at_cursor(bufnr)
- local hunks = M.parse_hunks(bufnr)
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
-
- for _, hunk in ipairs(hunks) do
- if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then
- return hunk
- end
- end
-
- return nil
-end
-
----@param hunk diffs.MergeHunkInfo
----@param working_bufnr integer
----@return diffs.ConflictRegion?
-function M.match_hunk_to_conflict(hunk, working_bufnr)
- local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
- local regions = conflict.parse(working_lines)
-
- for _, region in ipairs(regions) do
- local ours_lines = {}
- for line = region.ours_start + 1, region.ours_end do
- table.insert(ours_lines, working_lines[line])
- end
-
- if #ours_lines == #hunk.del_lines then
- local match = true
- for j = 1, #ours_lines do
- if ours_lines[j] ~= hunk.del_lines[j] then
- match = false
- break
- end
- end
- if match then
- return region
- end
- end
- end
-
- return nil
-end
-
----@param diff_bufnr integer
----@return integer?
-function M.get_or_load_working_buf(diff_bufnr)
- local ok, source = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_source')
- local working_path = ok and source.working_path
- if not working_path then
- return nil
- end
-
- local existing = vim.fn.bufnr(working_path)
- if existing ~= -1 then
- return existing
- end
-
- local bufnr = vim.fn.bufadd(working_path)
- vim.fn.bufload(bufnr)
- return bufnr
-end
-
----@param diff_bufnr integer
----@param hunk_index integer
-local function mark_resolved(diff_bufnr, hunk_index)
- if not resolved_hunks[diff_bufnr] then
- resolved_hunks[diff_bufnr] = {}
- end
- resolved_hunks[diff_bufnr][hunk_index] = true
-end
-
----@param diff_bufnr integer
----@param hunk_index integer
----@return boolean
-function M.is_resolved(diff_bufnr, hunk_index)
- return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false
-end
-
----@param diff_bufnr integer
----@param side 'ours'|'theirs'|'both'|'none'
-local function resolve(diff_bufnr, side)
- local hunk = M.find_hunk_at_cursor(diff_bufnr)
- if not hunk then
- return
- end
- if M.is_resolved(diff_bufnr, hunk.index) then
- notify('hunk already resolved', vim.log.levels.INFO)
- return
- end
- local working_bufnr = M.get_or_load_working_buf(diff_bufnr)
- if not working_bufnr then
- return
- end
- local region = M.match_hunk_to_conflict(hunk, working_bufnr)
- if not region then
- notify('hunk does not correspond to a conflict region', vim.log.levels.INFO)
- return
- end
- conflict.replace_region(
- working_bufnr,
- region,
- conflict.replacement_lines(working_bufnr, region, side)
- )
- conflict.refresh(working_bufnr)
- mark_resolved(diff_bufnr, hunk.index)
-end
-
----@param bufnr integer
-function M.resolve_ours(bufnr)
- resolve(bufnr, 'ours')
-end
-
----@param bufnr integer
-function M.resolve_theirs(bufnr)
- resolve(bufnr, 'theirs')
-end
-
----@param bufnr integer
-function M.resolve_both(bufnr)
- resolve(bufnr, 'both')
-end
-
----@param bufnr integer
-function M.resolve_none(bufnr)
- resolve(bufnr, 'none')
-end
-
----@param bufnr integer
----@param forward boolean
-local function goto_hunk(bufnr, forward)
- local hunks = M.parse_hunks(bufnr)
- if #hunks == 0 then
- return
- end
-
- local working_bufnr = M.get_or_load_working_buf(bufnr)
- if not working_bufnr then
- return
- end
-
- local candidates = {}
- for _, hunk in ipairs(hunks) do
- if not M.is_resolved(bufnr, hunk.index) and M.match_hunk_to_conflict(hunk, working_bufnr) then
- table.insert(candidates, hunk)
- end
- end
-
- if #candidates == 0 then
- return
- end
-
- local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1
-
- if forward then
- for _, hunk in ipairs(candidates) do
- if hunk.start_line > cursor_line then
- vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 })
- return
- end
- end
- notify('wrapped to first hunk', vim.log.levels.INFO)
- vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 })
- else
- for i = #candidates, 1, -1 do
- if candidates[i].start_line < cursor_line then
- vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 })
- return
- end
- end
- notify('wrapped to last hunk', vim.log.levels.INFO)
- vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 })
- end
-end
-
----@param bufnr integer
-function M.goto_next(bufnr)
- goto_hunk(bufnr, true)
-end
-
----@param bufnr integer
-function M.goto_prev(bufnr)
- goto_hunk(bufnr, false)
-end
-
----@param bufnr integer
-function M.attach(bufnr)
- resolved_hunks[bufnr] = nil
-
- vim.api.nvim_create_autocmd('BufWipeout', {
- buffer = bufnr,
- callback = function()
- resolved_hunks[bufnr] = nil
- end,
- })
- vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsMergeReady', data = { bufnr = bufnr } })
-end
-
-return M
diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua
index 7d676afa..a975443f 100644
--- a/lua/diffs/parser.lua
+++ b/lua/diffs/parser.lua
@@ -10,15 +10,13 @@
---@field header_lines string[]?
---@field prefix_width integer
---@field quote_width integer
----@field rail_width integer?
----@field rail_style diffs.RailStyle?
+---@field column_offset integer?
---@field _hl_line_count integer?
---@field _skipped_max_lines boolean?
local M = {}
local dbg = require('diffs.log').dbg
-local rails = require('diffs.rails')
---@type table
local ft_lang_cache = {}
@@ -110,11 +108,6 @@ end
---@param bufnr integer
---@return string?
local function get_repo_root(bufnr)
- local ok, source = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_source')
- if ok and source.repo_root then
- return source.repo_root
- end
-
local ok2, git_dir = pcall(vim.api.nvim_buf_get_var, bufnr, 'git_dir')
if ok2 and git_dir then
return vim.fn.fnamemodify(git_dir, ':h')
@@ -138,8 +131,7 @@ local function get_repo_root(bufnr)
end
local cwd = vim.fn.getcwd()
- local git = require('diffs.git')
- return git.get_repo_root(cwd .. '/.')
+ return vim.fs.root(cwd, '.git')
end
---@param bufnr integer
@@ -147,13 +139,11 @@ end
function M.parse_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local repo_root = get_repo_root(bufnr)
- local rail_width = rails.width_for_buffer(bufnr)
- local rail_style = rail_width > 0 and rails.style_for_buffer(bufnr) or nil
local quote_prefix = nil
local quote_width = 0
for _, l in ipairs(lines) do
- local logical = rails.strip(l, rail_width)
+ local logical = l
local qp = logical:match('^(>+ )diff %-%-') or logical:match('^(>+ )@@ %-')
if qp then
quote_prefix = qp
@@ -205,8 +195,6 @@ function M.parse_buffer(bufnr)
lines = hunk_lines,
prefix_width = hunk_prefix_width,
quote_width = current_quote_width,
- rail_width = rail_width > 0 and rail_width or nil,
- rail_style = rail_style,
}
if hunk_count == 1 and header_start and #header_lines > 0 then
hunk.header_start_line = header_start
@@ -223,12 +211,11 @@ function M.parse_buffer(bufnr)
end
for i, line in ipairs(lines) do
- local without_rails = rails.strip(line, rail_width)
- local logical = without_rails
+ local logical = line
if quote_prefix then
- if without_rails:sub(1, quote_width) == quote_prefix then
- logical = without_rails:sub(quote_width + 1)
- elseif without_rails:match('^>+$') then
+ if line:sub(1, quote_width) == quote_prefix then
+ logical = line:sub(quote_width + 1)
+ elseif line:match('^>+$') then
logical = ''
end
end
@@ -254,7 +241,7 @@ function M.parse_buffer(bufnr)
if filename then
flush_hunk()
current_filename = filename
- current_quote_width = rail_width + ((logical ~= without_rails) and quote_width or 0)
+ current_quote_width = (logical ~= line) and quote_width or 0
local cache_key = (repo_root or '') .. '\0' .. filename
local cached = ft_lang_cache[cache_key]
if cached then
@@ -315,6 +302,8 @@ function M.parse_buffer(bufnr)
if new_remaining and (prefix == ' ' or prefix == '+') then
new_remaining = new_remaining - 1
end
+ elseif logical:match('^\\ No newline') then
+ table.insert(hunk_lines, logical)
elseif
logical == ''
and old_remaining
diff --git a/lua/diffs/rails.lua b/lua/diffs/rails.lua
deleted file mode 100644
index 46621bad..00000000
--- a/lua/diffs/rails.lua
+++ /dev/null
@@ -1,288 +0,0 @@
-local M = {}
-
-local hunk_model = require('diffs.hunks')
-
-local separator = ' │ '
-local bar_slot = ' '
-
----@alias diffs.RailStyle "dual"|"single"
-
----@class diffs.RailInfo
----@field width integer
----@field prefix_width integer
----@field style diffs.RailStyle
-
----@class diffs.RailRanges
----@field old_start integer
----@field old_end integer
----@field new_start integer
----@field new_end integer
-
----@class diffs.RailNumberRange
----@field start integer
----@field finish integer
-
----@param style diffs.RailStyle?
----@return diffs.RailStyle
-local function normalize_rail_style(style)
- if style == 'single' then
- return 'single'
- end
- return 'dual'
-end
-
----@param value integer?
----@param width integer
----@return string
-local function format_lnum(value, width)
- if not value then
- return string.rep(' ', width)
- end
- return string.format('%' .. width .. 'd', value)
-end
-
----@param line diffs.DiffHunkLine?
----@return integer?
-local function old_lnum(line)
- if line and (line.kind == 'context' or line.kind == 'delete') then
- return line.old_lnum
- end
- return nil
-end
-
----@param line diffs.DiffHunkLine?
----@return integer?
-local function new_lnum(line)
- if line and (line.kind == 'context' or line.kind == 'add') then
- return line.new_lnum
- end
- return nil
-end
-
----@param line diffs.DiffHunkLine?
----@return integer?
-local function single_lnum(line)
- if not line then
- return nil
- end
- if line.kind == 'delete' then
- return line.old_lnum
- end
- if line.kind == 'context' or line.kind == 'add' then
- return line.new_lnum
- end
- return nil
-end
-
----@param line diffs.DiffHunkLine?
----@param text string
----@return boolean
-local function is_empty_context_line(line, text)
- return line ~= nil and line.kind == 'context' and text == ' '
-end
-
----@param line string
----@param start_col integer
----@param end_col integer
----@return boolean
-local function has_number(line, start_col, end_col)
- return line:sub(start_col + 1, end_col):find('%d') ~= nil
-end
-
----@param line string
----@param ranges diffs.RailNumberRange[]?
----@return boolean
-local function any_range_has_number(line, ranges)
- for _, range in ipairs(ranges or {}) do
- if has_number(line, range.start, range.finish) then
- return true
- end
- end
- return false
-end
-
----@param line string
----@param prefix_width integer
----@return boolean
-local function has_rail_number(line, prefix_width)
- return any_range_has_number(line, M.number_ranges(prefix_width, 'dual'))
- or any_range_has_number(line, M.number_ranges(prefix_width, 'single'))
-end
-
----@param lines string[]
----@return table, integer
-local function collect_lines(lines)
- local by_lnum = {}
- local max_lnum = 0
-
- for _, hunk in ipairs(hunk_model.parse(lines)) do
- for _, line in ipairs(hunk.lines) do
- by_lnum[line.lnum] = line
- max_lnum = math.max(max_lnum, line.old_lnum or 0, line.new_lnum or 0)
- end
- end
-
- return by_lnum, max_lnum
-end
-
----@param lines string[]
----@param opts? { rail_style?: diffs.RailStyle }
----@return string[], diffs.RailInfo?
-function M.annotate(lines, opts)
- opts = opts or {}
- local rail_style = normalize_rail_style(opts.rail_style)
- local compact_separator = separator:gsub('%s+$', '')
-
- local by_lnum, max_lnum = collect_lines(lines)
- if vim.tbl_isempty(by_lnum) then
- return lines, nil
- end
-
- local width = math.max(1, #tostring(max_lnum))
- local prefix_width = rail_style == 'single' and (#bar_slot + width + #separator)
- or (#bar_slot + width + 1 + width + #separator)
- local annotated = {}
-
- for lnum, text in ipairs(lines) do
- local line = by_lnum[lnum]
- local line_separator = is_empty_context_line(line, text) and compact_separator or separator
- local display_text = is_empty_context_line(line, text) and '' or text
- if rail_style == 'single' then
- annotated[lnum] = bar_slot
- .. format_lnum(single_lnum(line), width)
- .. line_separator
- .. display_text
- else
- annotated[lnum] = bar_slot
- .. format_lnum(old_lnum(line), width)
- .. ' '
- .. format_lnum(new_lnum(line), width)
- .. line_separator
- .. display_text
- end
- end
-
- return annotated,
- {
- width = width,
- prefix_width = prefix_width,
- style = rail_style,
- }
-end
-
----@param line string
----@param prefix_width integer?
----@return string
-function M.strip(line, prefix_width)
- if type(prefix_width) ~= 'number' or prefix_width <= 0 then
- return line
- end
- local stripped = line:sub(prefix_width + 1)
- if stripped == '' and has_rail_number(line, prefix_width) then
- return ' '
- end
- return stripped
-end
-
----@param lines string[]
----@param prefix_width integer?
----@return string[]
-function M.strip_lines(lines, prefix_width)
- if type(prefix_width) ~= 'number' or prefix_width <= 0 then
- return lines
- end
-
- local stripped = {}
- for i, line in ipairs(lines) do
- stripped[i] = M.strip(line, prefix_width)
- end
- return stripped
-end
-
----@param prefix_width integer?
----@return diffs.RailRanges?
-function M.ranges(prefix_width)
- if type(prefix_width) ~= 'number' or prefix_width <= 0 then
- return nil
- end
- local width = (prefix_width - #bar_slot - 1 - #separator) / 2
- if width < 1 or width % 1 ~= 0 then
- return nil
- end
-
- local old_start = #bar_slot
- local old_end = old_start + width
- local new_start = old_end + 1
- local new_end = new_start + width
-
- return {
- old_start = old_start,
- old_end = old_end,
- new_start = new_start,
- new_end = new_end,
- }
-end
-
----@param prefix_width integer?
----@param rail_style? diffs.RailStyle
----@return diffs.RailNumberRange[]?
-function M.number_ranges(prefix_width, rail_style)
- if type(prefix_width) ~= 'number' or prefix_width <= 0 then
- return nil
- end
- if normalize_rail_style(rail_style) == 'single' then
- local width = prefix_width - #bar_slot - #separator
- if width < 1 or width % 1 ~= 0 then
- return nil
- end
-
- return {
- {
- start = #bar_slot,
- finish = #bar_slot + width,
- },
- }
- end
-
- local ranges = M.ranges(prefix_width)
- if not ranges then
- return nil
- end
-
- return {
- {
- start = ranges.old_start,
- finish = ranges.old_end,
- },
- {
- start = ranges.new_start,
- finish = ranges.new_end,
- },
- }
-end
-
----@param bufnr integer
----@return integer
-function M.width_for_buffer(bufnr)
- local ok, width = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_width')
- if ok and type(width) == 'number' and width > 0 then
- return width
- end
- return 0
-end
-
----@param bufnr integer
----@return diffs.RailStyle
-function M.style_for_buffer(bufnr)
- local ok, style = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_style')
- if ok and style == 'single' then
- return 'single'
- end
- return 'dual'
-end
-
-M._test = {
- format_lnum = format_lnum,
-}
-
-return M
diff --git a/lua/diffs/render.lua b/lua/diffs/render.lua
deleted file mode 100644
index 8bffa0f9..00000000
--- a/lua/diffs/render.lua
+++ /dev/null
@@ -1,348 +0,0 @@
-local M = {}
-
-local content = require('diffs.content')
-local diffopt = require('diffs.diffopt')
-local diffspec = require('diffs.spec')
-local git = require('diffs.git')
-
----@class diffs.RenderFileOpts
----@field worktree_lines? diffs.ContentLines|string[]
----@field worktree_buf? integer
----@field empty_on_missing? boolean
----@field old_path? string
----@field new_path? string
-
----@class diffs.UnifiedLinesOpts
----@field old_missing? boolean
----@field new_missing? boolean
----@field new_mode? string
----@field deleted_mode? string
-
-local missing_errors = {
- ['file not in index'] = true,
- ['file not readable'] = true,
-}
-
----@param err string?
----@return boolean
-local function is_missing_error(err)
- return err ~= nil and (missing_errors[err] or err:match('^file not in revision: ') ~= nil)
-end
-
----@param lines string[]?
----@return boolean
-function M.has_binary_lines(lines)
- for _, line in ipairs(lines or {}) do
- if line:find('\0', 1, true) then
- return true
- end
- end
- return false
-end
-
----@param old_lines diffs.ContentLines|string[]
----@param new_lines diffs.ContentLines|string[]
----@param old_name string
----@param new_name string
----@param opts? diffs.UnifiedLinesOpts
----@return string[]
-function M.unified_lines(old_lines, new_lines, old_name, new_name, opts)
- opts = opts or {}
- local old_content = content.to_string(old_lines)
- local new_content = content.to_string(new_lines)
-
- local diff_output = vim.text.diff(
- old_content,
- new_content,
- vim.tbl_extend('force', {
- result_type = 'unified',
- ctxlen = 3,
- }, diffopt.vim_diff_opts())
- )
-
- ---@cast diff_output string
- if diff_output == '' then
- return {}
- end
-
- local diff_lines = vim.split(diff_output, '\n', { plain = true })
-
- local result = {
- 'diff --git a/' .. old_name .. ' b/' .. new_name,
- }
-
- if opts.old_missing then
- result[#result + 1] = 'new file mode ' .. (opts.new_mode or '100644')
- result[#result + 1] = '--- /dev/null'
- else
- result[#result + 1] = '--- a/' .. old_name
- end
-
- if opts.new_missing then
- if not opts.old_missing then
- table.insert(result, 2, 'deleted file mode ' .. (opts.deleted_mode or '100644'))
- end
- result[#result + 1] = '+++ /dev/null'
- else
- result[#result + 1] = '+++ b/' .. new_name
- end
-
- for _, line in ipairs(diff_lines) do
- table.insert(result, line)
- end
-
- return result
-end
-
----@param repo_root string
----@param path string
----@return string
-local function abs_path(repo_root, path)
- return repo_root .. '/' .. path
-end
-
----@param endpoint diffs.Endpoint
----@param filepath string
----@return string?
-local function file_mode(endpoint, filepath)
- endpoint = diffspec.endpoint(endpoint)
-
- if endpoint.kind == diffspec.endpoint_kind.tree then
- return git.get_tree_mode(endpoint.rev, filepath)
- end
-
- if endpoint.kind == diffspec.endpoint_kind.index then
- return git.get_index_mode(filepath)
- end
-
- if endpoint.kind == diffspec.endpoint_kind.worktree then
- return git.get_working_mode(filepath)
- end
-
- return nil
-end
-
----@param endpoint diffs.Endpoint
----@param filepath string
----@param opts? diffs.RenderFileOpts
----@return diffs.ContentLines|string[]?, string?, boolean
-function M.read_endpoint(endpoint, filepath, opts)
- endpoint = diffspec.endpoint(endpoint)
- opts = opts or {}
-
- local lines, err
- if endpoint.kind == diffspec.endpoint_kind.tree then
- lines, err = git.get_file_content(endpoint.rev, filepath)
- elseif endpoint.kind == diffspec.endpoint_kind.stage then
- lines, err = git.get_file_content(':' .. endpoint.stage, filepath)
- elseif endpoint.kind == diffspec.endpoint_kind.index then
- lines, err = git.get_index_content(filepath)
- elseif endpoint.kind == diffspec.endpoint_kind.worktree then
- lines = opts.worktree_lines
- local buf = opts.worktree_buf
- if
- not lines
- and buf
- and vim.api.nvim_buf_is_loaded(buf)
- and vim.api.nvim_buf_get_name(buf) == filepath
- then
- lines = content.from_buffer(buf)
- end
- if not lines then
- lines, err = git.get_working_content(filepath)
- end
- else
- return nil, 'unsupported endpoint: ' .. tostring(endpoint.kind), false
- end
-
- if not lines and opts.empty_on_missing and is_missing_error(err) then
- return {}, nil, true
- end
-
- return lines, err, false
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param args string[]
----@return string[]?
-local function build_git_diff_cmd(diff_spec, repo_root, args)
- local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
- for _, arg in ipairs(args) do
- table.insert(cmd, arg)
- end
-
- local left = diff_spec.left
- local right = diff_spec.right
-
- if
- left.kind == diffspec.endpoint_kind.index and right.kind == diffspec.endpoint_kind.worktree
- then
- return cmd
- end
-
- if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.index then
- table.insert(cmd, '--cached')
- table.insert(cmd, left.rev)
- return cmd
- end
-
- if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.worktree then
- table.insert(cmd, left.rev)
- return cmd
- end
-
- if left.kind == diffspec.endpoint_kind.tree and right.kind == diffspec.endpoint_kind.tree then
- table.insert(cmd, left.rev)
- table.insert(cmd, right.rev)
- return cmd
- end
-
- return nil
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param path string
----@param flag string
----@return string[]
-local function git_diff_lines(diff_spec, repo_root, path, flag)
- local cmd = build_git_diff_cmd(diff_spec, repo_root, { flag })
- if not cmd then
- return {}
- end
-
- table.insert(cmd, '--')
- table.insert(cmd, path)
- local result = vim.fn.systemlist(cmd)
- if vim.v.shell_error ~= 0 then
- return {}
- end
- return result
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param path string
----@return boolean
-local function has_raw_changes(diff_spec, repo_root, path)
- return #git_diff_lines(diff_spec, repo_root, path, '--raw') > 0
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param path string
----@return boolean
-local function has_binary_changes(diff_spec, repo_root, path)
- for _, line in ipairs(git_diff_lines(diff_spec, repo_root, path, '--numstat')) do
- if line:match('^%-%s+%-%s+') then
- return true
- end
- end
- return false
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param path string
----@return boolean
-local function has_renamed_or_copied_path(diff_spec, repo_root, path)
- local cmd = build_git_diff_cmd(diff_spec, repo_root, { '--name-status', '-M', '-C' })
- if not cmd then
- return false
- end
-
- local result = vim.fn.systemlist(cmd)
- if vim.v.shell_error ~= 0 then
- return false
- end
-
- for _, line in ipairs(result) do
- local status = line:match('^([RC]%d*)%s')
- if status then
- local fields = vim.split(line, '\t', { plain = true })
- for i = 2, #fields do
- if fields[i] == path then
- return true
- end
- end
- end
- end
-
- return false
-end
-
----@param diff_spec diffs.DiffSpec
----@param repo_root string
----@param opts? diffs.RenderFileOpts
----@return string[]?, string?, diffs.ContentLines|string[]?, diffs.ContentLines|string[]?
-function M.file(diff_spec, repo_root, opts)
- diff_spec = diffspec.new(diff_spec)
- opts = opts or {}
-
- if diff_spec.scope.kind ~= diffspec.scope_kind.file then
- return nil, 'unsupported DiffSpec scope: ' .. tostring(diff_spec.scope.kind)
- end
-
- local path = diff_spec.scope.path
- local old_path = opts.old_path or path
- local new_path = opts.new_path or path
- local old_filepath = abs_path(repo_root, old_path)
- local new_filepath = abs_path(repo_root, new_path)
-
- local old_mode = file_mode(diff_spec.left, old_filepath)
- local new_mode = file_mode(diff_spec.right, new_filepath)
- if old_mode == '160000' or new_mode == '160000' then
- return nil, 'diff does not support submodule changes'
- end
-
- if has_renamed_or_copied_path(diff_spec, repo_root, path) then
- return nil, 'diff does not support rename or copy changes'
- end
-
- if has_binary_changes(diff_spec, repo_root, path) then
- return nil, 'diff does not support binary files'
- end
-
- local old_lines, old_err, old_missing = M.read_endpoint(diff_spec.left, old_filepath, {
- worktree_lines = opts.worktree_lines,
- worktree_buf = opts.worktree_buf,
- empty_on_missing = true,
- })
- if not old_lines then
- return nil, old_err or 'could not read left endpoint'
- end
-
- local new_lines, new_err, new_missing = M.read_endpoint(diff_spec.right, new_filepath, {
- worktree_lines = opts.worktree_lines,
- worktree_buf = opts.worktree_buf,
- empty_on_missing = true,
- })
- if not new_lines then
- return nil, new_err or 'could not read right endpoint'
- end
-
- if M.has_binary_lines(old_lines) or M.has_binary_lines(new_lines) then
- return nil, 'diff does not support binary files'
- end
-
- local diff_lines = M.unified_lines(old_lines, new_lines, old_path, new_path, {
- old_missing = old_missing,
- new_missing = new_missing,
- new_mode = new_mode,
- deleted_mode = old_mode,
- })
- if
- #diff_lines == 0
- and old_mode
- and new_mode
- and old_mode ~= new_mode
- and has_raw_changes(diff_spec, repo_root, path)
- then
- return nil, 'diff does not support mode-only changes'
- end
-
- return diff_lines, nil, old_lines, new_lines
-end
-
-return M
diff --git a/lua/diffs/review.lua b/lua/diffs/review.lua
deleted file mode 100644
index 64308127..00000000
--- a/lua/diffs/review.lua
+++ /dev/null
@@ -1,735 +0,0 @@
-local M = {}
-
-local diffopt = require('diffs.diffopt')
-local diffspec = require('diffs.spec')
-local generated = require('diffs.generated')
-local git = require('diffs.git')
-local lists = require('diffs.lists')
-local log = require('diffs.log')
-local render = require('diffs.render')
-
-local dbg = log.dbg
-local notify = log.notify
-
----@param entry table
----@return boolean
-local function is_skipped_entry(entry)
- return type(entry.hunks) == 'table' and #entry.hunks == 0
-end
-
----@class diffs.ReviewSpec
----@field args? string[]
----@field base? string
----@field target? string
----@field repo? string
----@field mode? string
----@field vertical? boolean
----@field untracked? boolean
-
----@class diffs.ReviewCommandParseResult
----@field spec diffs.ReviewSpec
----@field layout "unified"|"stacked"|"split"
-
----@class diffs.NormalizedReview
----@field args? string[]
----@field base string
----@field target string?
----@field repo_root string
----@field mode string?
----@field vertical boolean
----@field untracked boolean
----@field display string
-
----@class diffs.ReviewDeps
----@field create_generated_diff_buffer fun(opts: diffs.GeneratedDiffBufferOpts): integer
----@field show_generated_diff_buffer fun(bufnr: integer, vertical?: boolean): boolean
----@field attach_generated_diff_buffer fun(bufnr: integer)
----@field replace_combined_diffs fun(lines: string[], repo_root: string): string[]
----@field rail_style? diffs.RailStyle
----@field review_layout? diffs.ReviewMapLayout
----@field setup_review_map_buffer? function
-
----@param args? string
----@return string[]
-local function split_args(args)
- args = vim.trim(args or '')
- if args == '' then
- return {}
- end
- return vim.split(args, '%s+', { trimempty = true })
-end
-
----@param repo? string
----@return string?
-local function resolve_repo_root(repo)
- if repo and repo ~= '' then
- return git.get_repo_root(repo .. '/.')
- end
-
- local bufnr = vim.api.nvim_get_current_buf()
- local filepath = vim.api.nvim_buf_get_name(bufnr)
- local repo_root = git.get_repo_root(filepath ~= '' and filepath or nil)
- if repo_root then
- return repo_root
- end
-
- local cwd = vim.fn.getcwd()
- return git.get_repo_root(cwd .. '/.')
-end
-
----@param repo_root string
----@return string?
-local function default_branch(repo_root)
- local ref =
- vim.fn.systemlist({ 'git', '-C', repo_root, 'symbolic-ref', 'refs/remotes/origin/HEAD' })
- if vim.v.shell_error ~= 0 or not ref[1] or ref[1] == '' then
- return nil
- end
- return (ref[1]:gsub('^refs/remotes/', ''))
-end
-
----@param cmd string[]
----@return string[]?, string?
-local function git_lines(cmd)
- local stdout, stderr = {}, {}
- local job = vim.fn.jobstart(cmd, {
- stdout_buffered = true,
- stderr_buffered = true,
- on_stdout = function(_, data)
- stdout = data
- end,
- on_stderr = function(_, data)
- stderr = data
- end,
- })
- if job <= 0 then
- return nil, 'could not start Git'
- end
- local code = vim.fn.jobwait({ job })[1]
- local message = vim.trim(table.concat(stderr, '\n'))
- if code ~= 0 then
- return nil, message
- end
- if message ~= '' then
- notify(message, vim.log.levels.WARN)
- end
- if stdout[#stdout] == '' then
- table.remove(stdout)
- end
- return stdout, nil
-end
-
----@param repo_root string
----@param base string
----@param target string
----@return string?, string?
-local function merge_base(repo_root, base, target)
- local result, err =
- git_lines({ 'git', '-C', repo_root, 'merge-base', '--all', '--end-of-options', base, target })
- if not result then
- return nil, err ~= '' and err or 'no merge base for ' .. base .. ' and ' .. target
- end
- return result[1], nil
-end
-
----@param arg? string
----@return diffs.ReviewSpec
-function M.parse_arg(arg)
- if not arg or arg == '' then
- return {}
- end
-
- local base, separator, target = arg:match('^(.-)(%.%.%.?)(.*)$')
- if base then
- return {
- base = base ~= '' and base or 'HEAD',
- target = target ~= '' and target or 'HEAD',
- mode = separator == '...' and 'merge-base' or 'direct',
- }
- end
-
- return { base = arg }
-end
-
----@param args? string
----@return diffs.ReviewCommandParseResult
-function M.parse_command_args(args)
- local tokens = split_args(args)
- local layout = 'unified'
- local untracked = nil
-
- while tokens[1] and (tokens[1]:match('^%+%+layout=') or tokens[1] == '++nountracked') do
- local token = tokens[1]
- if token:match('^%+%+layout=') then
- layout = token:match('^%+%+layout=(.*)$')
- else
- untracked = false
- end
- table.remove(tokens, 1)
- end
-
- local spec = #tokens > 1 and { args = tokens } or M.parse_arg(tokens[1])
- spec.untracked = untracked
-
- return {
- spec = spec,
- layout = layout,
- }
-end
-
----@param spec? diffs.ReviewSpec
----@param repo_root_override? string
----@return diffs.NormalizedReview?, string?
-function M.normalize(spec, repo_root_override)
- spec = spec or {}
- local repo_root = repo_root_override or resolve_repo_root(spec.repo)
- if not repo_root then
- return nil, 'not in a git repository'
- end
-
- local base = spec.args and spec.args[1] or spec.base
- if not base or base == '' then
- base = default_branch(repo_root)
- if not base then
- return nil, 'cannot detect default branch (try: git remote set-head origin -a)'
- end
- end
-
- local target = spec.args and spec.args[2] or spec.target
- local mode = spec.args and 'direct' or spec.mode or (target and 'merge-base')
- local display = spec.args and table.concat(spec.args, ' ')
- or (target and (base .. (mode == 'merge-base' and '...' or '..') .. target) or base)
-
- return {
- args = spec.args,
- base = base,
- target = target,
- repo_root = repo_root,
- mode = mode,
- vertical = spec.vertical or false,
- untracked = spec.untracked ~= false,
- display = display,
- },
- nil
-end
-
----@param review diffs.NormalizedReview
----@param args string[]
----@return string[]
-local function diff_cmd(review, args)
- local cmd = {
- 'git',
- '-C',
- review.repo_root,
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- }
- vim.list_extend(cmd, diffopt.git_flags())
- vim.list_extend(cmd, args)
- cmd[#cmd + 1] = '--'
- return cmd
-end
-
----@param review diffs.NormalizedReview
----@return string[]
-function M.build_cmd(review)
- local args = { '--end-of-options' }
- vim.list_extend(args, review.args or { review.display })
- return diff_cmd(review, args)
-end
-
----@param line string
----@return string?
-local function diff_line_file(line)
- local old_path, new_path = line:match('^diff %-%-git %a/(.-) %a/(.+)$')
- return new_path or old_path
-end
-
----@param diff_lines string[]
----@return table
-local function combined_diff_files(diff_lines)
- local files = {}
- for _, line in ipairs(diff_lines) do
- local file = line:match('^diff %-%-cc (.+)$') or line:match('^diff %-%-combined (.+)$')
- if file then
- files[file] = true
- end
- end
- return files
-end
-
----@param diff_lines string[]
----@return table[]
-local function file_records(diff_lines)
- local records = {}
- local current = nil
- for lnum, line in ipairs(diff_lines) do
- local file = diff_line_file(line)
- if file then
- if current then
- current.finish = lnum - 1
- end
- current = {
- file = file,
- start = lnum,
- finish = #diff_lines,
- }
- records[#records + 1] = current
- end
- end
- return records
-end
-
----@param path string
----@return diffs.DiffSpec
-local function unmerged_stage_spec(path)
- return diffspec.rev_to_rev(':2', ':3', path)
-end
-
----@param diff_lines string[]
----@param spec_for_file fun(file: string): diffs.DiffSpec
----@return table
-local function section_specs(diff_lines, spec_for_file)
- local specs = {}
- for _, record in ipairs(file_records(diff_lines)) do
- specs[record.file] = spec_for_file(record.file)
- end
- return specs
-end
-
----@param review diffs.NormalizedReview
----@param base_rev string
----@return string[]?, string?
-local function branch_lines(review, base_rev)
- return git_lines(diff_cmd(review, { '--end-of-options', base_rev, 'HEAD' }))
-end
-
----@param review diffs.NormalizedReview
----@param cached boolean
----@return string[]?, string?
-local function worktree_edge_lines(review, cached)
- return git_lines(diff_cmd(review, cached and { '--cached' } or {}))
-end
-
----@param review diffs.NormalizedReview
----@return string[]?, string?
-local function untracked_paths(review)
- local paths = vim.fn.systemlist({
- 'git',
- '-C',
- review.repo_root,
- 'ls-files',
- '--others',
- '--exclude-standard',
- })
- if vim.v.shell_error ~= 0 then
- return nil, table.concat(paths or {}, '\n')
- end
-
- table.sort(paths)
- return paths, nil
-end
-
----@param repo_root string
----@param path string
----@return boolean
-local function is_untracked_binary(repo_root, path)
- local result = vim.fn.systemlist({
- 'git',
- '-C',
- repo_root,
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--no-index',
- '--numstat',
- '--',
- '/dev/null',
- path,
- })
- if vim.v.shell_error ~= 0 and vim.v.shell_error ~= 1 then
- return false
- end
-
- for _, line in ipairs(result or {}) do
- if line:match('^%-%s+%-%s+') then
- return true
- end
- end
- return false
-end
-
----@param lines string[]
----@param section table
----@param specs table
----@param state table
-local function append_section(lines, section, specs, state)
- local records = file_records(section.lines)
- if #records == 0 then
- return
- end
-
- local header_lnum = #lines + 1
- lines[#lines + 1] = ('# %s: %s'):format(section.label, section.description)
- local section_start = header_lnum
- for _, line in ipairs(section.lines) do
- lines[#lines + 1] = line
- end
-
- local section_info = {
- id = section.id,
- label = section.label,
- start = section_start,
- finish = #lines,
- }
- state.sections[#state.sections + 1] = section_info
-
- for _, record in ipairs(records) do
- local key = section.id .. ':' .. record.file
- state.records[#state.records + 1] = {
- key = key,
- file = record.file,
- section = section.id,
- section_label = section.label,
- diff_spec = specs[record.file],
- start = section_start + record.start,
- finish = section_start + record.finish,
- }
- end
-end
-
----@param records table[]
----@param lnum integer
----@param file string
----@return table?
-local function record_for_line(records, lnum, file)
- for _, record in ipairs(records or {}) do
- if record.file == file and lnum >= record.start and lnum <= record.finish then
- return record
- end
- end
- return nil
-end
-
----@param records table[]
----@return fun(lnum: integer, file: string): table?
-local function metadata_for_records(records)
- return function(lnum, file)
- return record_for_line(records, lnum, file)
- end
-end
-
----@param review diffs.NormalizedReview
----@param deps diffs.ReviewDeps
----@return string[]?, string?, table?
-local function run_current_state(review, deps)
- local base_rev, merge_err = merge_base(review.repo_root, review.base, 'HEAD')
- if not base_rev then
- return nil, merge_err, nil
- end
-
- local lines = {}
- local state = {
- records = {},
- sections = {},
- }
-
- local branch, branch_err = branch_lines(review, base_rev)
- if not branch then
- return nil, branch_err ~= '' and branch_err or 'git diff failed for review Branch section', nil
- end
- local branch_rendered = deps.replace_combined_diffs(branch, review.repo_root)
- local branch_specs = section_specs(branch_rendered, function(file)
- return diffspec.rev_to_rev(base_rev, 'HEAD', file)
- end)
- append_section(lines, {
- id = 'branch',
- label = 'Branch',
- description = review.base .. '...' .. 'HEAD',
- lines = branch_rendered,
- }, branch_specs, state)
-
- local staged, staged_err = worktree_edge_lines(review, true)
- if not staged then
- return nil, staged_err ~= '' and staged_err or 'git diff failed for review Staged section', nil
- end
- local staged_rendered = deps.replace_combined_diffs(staged, review.repo_root)
- local staged_unmerged = combined_diff_files(staged)
- local staged_specs = section_specs(staged_rendered, function(file)
- return staged_unmerged[file] and unmerged_stage_spec(file) or diffspec.head_to_index(file)
- end)
- append_section(lines, {
- id = 'staged',
- label = 'Staged',
- description = 'HEAD -> index',
- lines = staged_rendered,
- }, staged_specs, state)
-
- local unstaged, unstaged_err = worktree_edge_lines(review, false)
- if not unstaged then
- return nil,
- unstaged_err ~= '' and unstaged_err or 'git diff failed for review Unstaged section',
- nil
- end
- local unstaged_rendered = deps.replace_combined_diffs(unstaged, review.repo_root)
- local unstaged_unmerged = combined_diff_files(unstaged)
- local unstaged_specs = section_specs(unstaged_rendered, function(file)
- return unstaged_unmerged[file] and unmerged_stage_spec(file) or diffspec.index_to_worktree(file)
- end)
- append_section(lines, {
- id = 'unstaged',
- label = 'Unstaged',
- description = 'index -> worktree',
- lines = unstaged_rendered,
- }, unstaged_specs, state)
-
- if review.untracked then
- local untracked, untracked_err = untracked_paths(review)
- if not untracked then
- return nil,
- untracked_err ~= '' and untracked_err or 'git ls-files failed for review Untracked section',
- nil
- end
- local untracked_lines = {}
- local untracked_specs = {}
- for _, path in ipairs(untracked) do
- if is_untracked_binary(review.repo_root, path) then
- dbg('skipping binary untracked %s', path)
- else
- local spec = diffspec.index_to_worktree(path)
- local rendered, render_err = render.file(spec, review.repo_root)
- if rendered then
- for _, line in ipairs(rendered) do
- untracked_lines[#untracked_lines + 1] = line
- end
- untracked_specs[path] = spec
- elseif render_err then
- dbg('skipping untracked %s: %s', path, render_err)
- end
- end
- end
- append_section(lines, {
- id = 'untracked',
- label = 'Untracked',
- description = 'empty -> worktree',
- lines = untracked_lines,
- }, untracked_specs, state)
- end
-
- return lines,
- nil,
- {
- metadata_for_line = metadata_for_records(state.records),
- sections = state.sections,
- store_hunks = true,
- is_skipped = is_skipped_entry,
- }
-end
-
----@param review_spec diffs.ReviewSpec?
----@param repo_root string
----@param path string
----@param selection? diffs.GeneratedFileSelection
----@return diffs.DiffSpec?, diffs.NormalizedReview?, string?
-function M.diff_spec_for_file(review_spec, repo_root, path, selection)
- local normalized, normalize_err = M.normalize(review_spec, repo_root)
- if not normalized then
- return nil, nil, normalize_err
- end
-
- if selection and selection.diff_spec then
- return diffspec.new(selection.diff_spec), normalized, nil
- end
-
- if not normalized.target then
- return diffspec.rev_to_worktree(normalized.base, path), normalized, nil
- end
-
- if normalized.mode == 'merge-base' then
- local base_rev, merge_err = merge_base(normalized.repo_root, normalized.base, normalized.target)
- if not base_rev then
- return nil, normalized, merge_err
- end
- return diffspec.rev_to_rev(base_rev, normalized.target, path), normalized, nil
- end
-
- return diffspec.rev_to_rev(normalized.base, normalized.target, path), normalized, nil
-end
-
----@param normalized diffs.NormalizedReview
----@return diffs.ReviewSpec
-function M.stored_review_spec(normalized)
- local spec = normalized.args and { args = normalized.args }
- or {
- base = normalized.base,
- target = normalized.target,
- mode = normalized.mode,
- }
- spec.untracked = normalized.untracked
- return spec
-end
-
----@param review diffs.NormalizedReview
----@return string
-function M.buffer_name(review)
- return 'diffs://review:' .. review.display
-end
-
----@param repo_root? string
----@return string[]
-local function list_refs(repo_root)
- local cmd = { 'git' }
- if repo_root then
- table.insert(cmd, '-C')
- table.insert(cmd, repo_root)
- end
- vim.list_extend(cmd, {
- 'for-each-ref',
- '--format=%(refname:short)',
- 'refs/heads/',
- 'refs/remotes/',
- 'refs/tags/',
- })
- local refs = vim.fn.systemlist(cmd)
- if vim.v.shell_error ~= 0 then
- return {}
- end
- return refs
-end
-
----@param arglead string
----@return string[]
-function M.complete(arglead)
- local prefix, tail = arglead:match('^(.-%.%.%.?)(.*)$')
- prefix, tail = prefix or '', tail or arglead
- local matches = {}
- for _, ref in ipairs(list_refs(resolve_repo_root(nil))) do
- if ref:find(tail, 1, true) == 1 then
- table.insert(matches, prefix .. ref)
- end
- end
- return matches
-end
-
----@param review diffs.NormalizedReview
----@param deps diffs.ReviewDeps
----@return string[]?, string?
-function M.run(review, deps)
- local result, err = git_lines(M.build_cmd(review))
- if not result then
- return nil, err
- end
- return deps.replace_combined_diffs(result, review.repo_root), nil
-end
-
----@param review diffs.NormalizedReview
----@param deps diffs.ReviewDeps
----@return string[]?, string?, table?
-function M.render(review, deps)
- if not review.target then
- return run_current_state(review, deps)
- end
-
- local result, err = M.run(review, deps)
- return result, err, { is_skipped = is_skipped_entry }
-end
-
----@param spec? diffs.ReviewSpec
----@param deps diffs.ReviewDeps
----@return integer?
-function M.open(spec, deps)
- local review, err = M.normalize(spec)
- if not review then
- notify(err, vim.log.levels.ERROR)
- return nil
- end
-
- local target_name = M.buffer_name(review)
- local existing_buf = vim.fn.bufnr(target_name)
- if existing_buf ~= -1 then
- pcall(vim.api.nvim_buf_delete, existing_buf, { force = true })
- end
-
- local result, diff_err, list_opts = M.render(review, deps)
- if not result then
- notify(diff_err, vim.log.levels.ERROR)
- return nil
- end
- if #result == 0 then
- notify('no diff against ' .. review.display, vim.log.levels.INFO)
- return nil
- end
-
- local review_layout = deps.review_layout or 'unified'
- local diff_buf = deps.create_generated_diff_buffer({
- name = target_name,
- lines = result,
- repo_root = review.repo_root,
- source = generated.review_source(review.repo_root, M.stored_review_spec(review)),
- rail_style = deps.rail_style,
- vars = {
- diffs_review = { display = review.display, layout = review_layout },
- },
- })
-
- if deps.setup_review_map_buffer then
- deps.setup_review_map_buffer(diff_buf, review.display, review_layout)
- end
- if not deps.show_generated_diff_buffer(diff_buf, review.vertical) then
- pcall(vim.api.nvim_buf_delete, diff_buf, { force = true })
- return nil
- end
- lists.set_for_unified_buffer(diff_buf, result, {
- title = 'review: ' .. review.display,
- loclist_title = 'review hunks: ' .. review.display,
- metadata_for_line = list_opts and list_opts.metadata_for_line,
- sections = list_opts and list_opts.sections,
- store_hunks = list_opts and list_opts.store_hunks,
- is_skipped = list_opts and list_opts.is_skipped,
- quickfix = true,
- })
-
- deps.attach_generated_diff_buffer(diff_buf)
- dbg('opened review buffer %d (%s)', diff_buf, review.display)
-
- return diff_buf
-end
-
----@param buf integer
----@param lnum integer
----@return string?
-function M.file_at_line(buf, lnum)
- local lines = vim.api.nvim_buf_get_lines(buf, 0, lnum, false)
- for i = #lines, 1, -1 do
- local file = lines[i]:match('^diff %-%-git %a/.+ %a/(.+)$')
- if file then
- return file
- end
- end
- return nil
-end
-
----@param review_spec diffs.ReviewSpec?
----@param repo_root string
----@param deps diffs.ReviewDeps
----@return string[]?, string?, table?
-local function reload_spec(review_spec, repo_root, deps)
- local normalized, normalize_err = M.normalize(review_spec, repo_root)
- if not normalized then
- return nil, normalize_err, nil
- end
-
- return M.render(normalized, deps)
-end
-
----@param source diffs.GeneratedBufferSource
----@param deps diffs.ReviewDeps
----@return string[]?, string?, table?
-function M.reload_source(source, deps)
- return reload_spec(source.review, source.repo_root, deps)
-end
-
-return M
diff --git a/lua/diffs/runtime/attach.lua b/lua/diffs/runtime/attach.lua
index 2c86f4cd..fe4e3df4 100644
--- a/lua/diffs/runtime/attach.lua
+++ b/lua/diffs/runtime/attach.lua
@@ -1,82 +1,90 @@
local integrations = require('diffs.integrations')
-local log = require('diffs.log')
local M = {}
----@class diffs.AttachOpts
+---@class diffs.AttachOptions
+---@field hunks? fun(bufnr: integer): diffs.Hunk[]
+---@field intra? boolean
+
+---@class diffs.Attachment: diffs.AttachOptions
+---@field augroup integer
+
+---@class diffs.DetachOpts
---@field cache diffs.Cache
----@field attached_buffers table
+---@field attached_buffers table
+
+---@class diffs.AttachOpts: diffs.DetachOpts
---@field get_config fun(): diffs.Config
---@field refresh fun(bufnr?: integer)
----@param opts diffs.AttachOpts
+---@param opts diffs.DetachOpts
---@param bufnr? integer
-function M.attach(opts, bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
+function M.detach(opts, bufnr)
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
+ local attachment = opts.attached_buffers[bufnr]
+ if not attachment then
+ return
+ end
+ opts.attached_buffers[bufnr] = nil
+ opts.cache:delete(bufnr)
+ vim.api.nvim_del_augroup_by_id(attachment.augroup)
+ if vim.api.nvim_buf_is_valid(bufnr) then
+ vim.api.nvim_buf_clear_namespace(bufnr, opts.cache.ns, 0, -1)
+ end
+end
+---@param opts diffs.AttachOpts
+---@param bufnr? integer
+---@param options? diffs.AttachOptions
+function M.attach(opts, bufnr, options)
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
-
if opts.attached_buffers[bufnr] then
- return
+ if not options then
+ return
+ end
+ M.detach(opts, bufnr)
end
- opts.attached_buffers[bufnr] = true
-
+ local attachment = {
+ hunks = options and options.hunks,
+ intra = options and options.intra,
+ augroup = vim.api.nvim_create_augroup('diffs_buffer_' .. bufnr, { clear = true }),
+ }
+ opts.attached_buffers[bufnr] = attachment
local config = opts.get_config()
- local neogit_augroup = nil
- if
- integrations.is_enabled(config.integrations.neogit)
- and integrations.matches_filetype('neogit', vim.bo[bufnr].filetype)
- then
- vim.b[bufnr].neogit_disable_hunk_highlight = true
- neogit_augroup = vim.api.nvim_create_augroup('diffs_neogit_' .. bufnr, { clear = true })
- vim.api.nvim_create_autocmd('User', {
- pattern = 'NeogitDiffLoaded',
- group = neogit_augroup,
- callback = function()
- if vim.api.nvim_buf_is_valid(bufnr) and opts.attached_buffers[bufnr] then
- opts.refresh(bufnr)
- end
- end,
- })
+ for _, name in ipairs({ 'neogit', 'neojj' }) do
+ if
+ integrations.is_enabled(config.integrations[name])
+ and integrations.matches_filetype(name, vim.bo[bufnr].filetype)
+ then
+ vim.b[bufnr][name .. '_disable_hunk_highlight'] = true
+ vim.api.nvim_create_autocmd('User', {
+ pattern = name == 'neogit' and 'NeogitDiffLoaded' or 'NeojjDiffLoaded',
+ group = attachment.augroup,
+ callback = function()
+ if vim.api.nvim_buf_is_valid(bufnr) and opts.attached_buffers[bufnr] == attachment then
+ opts.refresh(bufnr)
+ end
+ end,
+ })
+ end
end
-
- local neojj_augroup = nil
- if
- integrations.is_enabled(config.integrations.neojj)
- and integrations.matches_filetype('neojj', vim.bo[bufnr].filetype)
- then
- vim.b[bufnr].neojj_disable_hunk_highlight = true
- neojj_augroup = vim.api.nvim_create_augroup('diffs_neojj_' .. bufnr, { clear = true })
- vim.api.nvim_create_autocmd('User', {
- pattern = 'NeojjDiffLoaded',
- group = neojj_augroup,
- callback = function()
- if vim.api.nvim_buf_is_valid(bufnr) and opts.attached_buffers[bufnr] then
- opts.refresh(bufnr)
- end
- end,
- })
- end
-
- log.dbg('attaching to buffer %d', bufnr)
-
- opts.cache:ensure(bufnr)
-
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
+ group = attachment.augroup,
callback = function()
- opts.attached_buffers[bufnr] = nil
- opts.cache:delete(bufnr)
- if neogit_augroup then
- pcall(vim.api.nvim_del_augroup_by_id, neogit_augroup)
- end
- if neojj_augroup then
- pcall(vim.api.nvim_del_augroup_by_id, neojj_augroup)
+ if opts.attached_buffers[bufnr] == attachment then
+ M.detach(opts, bufnr)
end
end,
})
+ opts.cache:ensure(bufnr)
end
return M
diff --git a/lua/diffs/runtime/cache.lua b/lua/diffs/runtime/cache.lua
index 903f76c7..b21af21f 100644
--- a/lua/diffs/runtime/cache.lua
+++ b/lua/diffs/runtime/cache.lua
@@ -14,13 +14,15 @@ local M = {}
---@class diffs.Cache
---@field ns integer
---@field get_config fun(): diffs.Config
+---@field get_attachment? fun(bufnr: integer): diffs.Attachment?
---@field hunk_cache table
----@field ft_retry_pending table
----@field syntax_jobs table
+---@field ft_retry_pending table
+---@field syntax_jobs table
---@class diffs.CacheOpts
---@field ns integer
---@field get_config fun(): diffs.Config
+---@field get_attachment? fun(bufnr: integer): diffs.Attachment?
local Cache = {}
Cache.__index = Cache
@@ -40,7 +42,7 @@ function M.find_visible_hunks(hunks, toprow, botrow)
while lo < hi do
local mid = math.floor((lo + hi) / 2)
local h = hunks[mid]
- local bottom = h.start_line - 1 + #h.lines - 1
+ local bottom = h.start_line + #h.lines - 1
if bottom < toprow then
lo = mid + 1
else
@@ -94,6 +96,7 @@ end
function M.new(opts)
return setmetatable({
ns = opts.ns,
+ get_attachment = opts.get_attachment,
get_config = opts.get_config,
hunk_cache = {},
ft_retry_pending = {},
@@ -102,9 +105,9 @@ function M.new(opts)
end
---@param bufnr integer
----@return integer
+---@return table
function Cache:next_syntax_job(bufnr)
- local job_id = (self.syntax_jobs[bufnr] or 0) + 1
+ local job_id = {}
self.syntax_jobs[bufnr] = job_id
return job_id
end
@@ -140,7 +143,7 @@ end
---@param bufnr integer
---@param tick integer
---@param changedtick integer
----@param job_id integer
+---@param job_id table
---@param deferred_syntax diffs.Hunk[]
---@return boolean
function Cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_syntax)
@@ -149,9 +152,9 @@ function Cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_sy
end
if self.syntax_jobs[bufnr] ~= job_id then
log.dbg(
- 'deferred syntax job superseded: cur=%s job=%d',
+ 'deferred syntax job superseded: cur=%s job=%s',
tostring(self.syntax_jobs[bufnr]),
- job_id
+ tostring(job_id)
)
return false
end
@@ -205,7 +208,7 @@ end
---@param bufnr integer
function Cache:invalidate(bufnr)
- self.syntax_jobs[bufnr] = (self.syntax_jobs[bufnr] or 0) + 1
+ self.syntax_jobs[bufnr] = {}
local entry = self.hunk_cache[bufnr]
if entry then
entry.tick = -1
@@ -223,7 +226,9 @@ function Cache:ensure(bufnr)
if entry and entry.tick == tick then
return
end
- local hunks = parser.parse_buffer(bufnr)
+ local attachment = self.get_attachment and self.get_attachment(bufnr)
+ local hunks = attachment and attachment.hunks and vim.deepcopy(attachment.hunks(bufnr))
+ or parser.parse_buffer(bufnr)
log.dbg('parsed %d hunks in buffer %d (tick %d)', #hunks, bufnr, tick)
self.hunk_cache[bufnr] = {
hunks = hunks,
@@ -239,9 +244,18 @@ function Cache:ensure(bufnr)
has_nil_ft = true
end
end
- if has_nil_ft and vim.fn.did_filetype() ~= 0 and not self.ft_retry_pending[bufnr] then
- self.ft_retry_pending[bufnr] = true
+ if
+ not (attachment and attachment.hunks)
+ and has_nil_ft
+ and vim.fn.did_filetype() ~= 0
+ and not self.ft_retry_pending[bufnr]
+ then
+ local retry = {}
+ self.ft_retry_pending[bufnr] = retry
vim.schedule(function()
+ if self.ft_retry_pending[bufnr] ~= retry then
+ return
+ end
if vim.api.nvim_buf_is_valid(bufnr) and self.hunk_cache[bufnr] then
log.dbg('retrying filetype detection for buffer %d (was blocked by did_filetype)', bufnr)
self:invalidate(bufnr)
diff --git a/lua/diffs/runtime/decorator.lua b/lua/diffs/runtime/decorator.lua
index 1eea9dab..01985ff8 100644
--- a/lua/diffs/runtime/decorator.lua
+++ b/lua/diffs/runtime/decorator.lua
@@ -7,7 +7,7 @@ local M = {}
---@class diffs.DecoratorOpts
---@field ns integer
---@field cache diffs.Cache
----@field attached_buffers table
+---@field attached_buffers table
---@field get_config fun(): diffs.Config
---@field get_fast_hl_opts fun(): diffs.HunkOpts
@@ -35,6 +35,11 @@ function M.setup(opts)
return false
end
local entry = cache.hunk_cache[bufnr]
+ if entry and entry.tick == -1 then
+ cache:ensure(bufnr)
+ cache:process_pending_clear(bufnr)
+ entry = cache.hunk_cache[bufnr]
+ end
if not entry then
return
end
@@ -47,11 +52,13 @@ function M.setup(opts)
local deferred_syntax = {}
local skipped_count = 0
local count = 0
- local fast_hl_opts = opts.get_fast_hl_opts()
+ local attachment = attached_buffers[bufnr]
+ local fast_hl_opts =
+ vim.tbl_extend('force', opts.get_fast_hl_opts(), { intra = attachment.intra })
for i = first, last do
if not entry.highlighted[i] then
local hunk = entry.hunks[i]
- local clear_start = hunk.start_line - 1
+ local clear_start = math.max(0, hunk.start_line - 1)
local clear_end = hunk.start_line + #hunk.lines
if hunk.header_start_line then
clear_start = hunk.header_start_line - 1
@@ -72,6 +79,9 @@ function M.setup(opts)
entry.warned_max_lines = true
local n = skipped_count
vim.schedule(function()
+ if attached_buffers[bufnr] ~= attachment then
+ return
+ end
log.notify(
(
'Syntax highlighting skipped for %d hunk(s) — too large.'
@@ -86,11 +96,11 @@ function M.setup(opts)
local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
local job_id = cache:next_syntax_job(bufnr)
log.dbg(
- 'deferred syntax scheduled: %d hunks tick=%d changedtick=%d job=%d',
+ 'deferred syntax scheduled: %d hunks tick=%d changedtick=%d job=%s',
#deferred_syntax,
tick,
changedtick,
- job_id
+ tostring(job_id)
)
vim.schedule(function()
cache:run_deferred_syntax(bufnr, tick, changedtick, job_id, deferred_syntax)
diff --git a/lua/diffs/runtime/diff_windows.lua b/lua/diffs/runtime/diff_windows.lua
index b5ae57eb..13a70dff 100644
--- a/lua/diffs/runtime/diff_windows.lua
+++ b/lua/diffs/runtime/diff_windows.lua
@@ -9,32 +9,6 @@ local WINHIGHLIGHT = table.concat({
'DiffText:DiffsDiffText',
}, ',')
-local CONFLICT_WINHIGHLIGHT = table.concat({
- 'DiffAdd:DiffsDiffOff',
- 'DiffDelete:DiffsDiffOff',
- 'DiffChange:DiffsDiffOff',
- 'DiffText:DiffsDiffOff',
-}, ',')
-
----@param win integer
----@return boolean
-local function is_split_pane(win)
- local buf = vim.api.nvim_win_get_buf(win)
- local ok, source = pcall(vim.api.nvim_buf_get_var, buf, 'diffs_source')
- return ok and source.kind == 'split_endpoint'
-end
-
----@param buf integer
----@return boolean
-local function has_conflict_markers(buf)
- for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
- if line:match('^<<<<<<<') then
- return true
- end
- end
- return false
-end
-
---@param diff_windows table
function M.attach(diff_windows)
local tabpage = vim.api.nvim_get_current_tabpage()
@@ -43,7 +17,7 @@ function M.attach(diff_windows)
local diff_wins = {}
for _, win in ipairs(wins) do
- if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff and not is_split_pane(win) then
+ if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then
table.insert(diff_wins, win)
end
end
@@ -53,15 +27,11 @@ function M.attach(diff_windows)
end
for _, win in ipairs(diff_wins) do
- local buf = vim.api.nvim_win_get_buf(win)
- local winhighlight = has_conflict_markers(buf) and CONFLICT_WINHIGHLIGHT or WINHIGHLIGHT
local current = vim.api.nvim_get_option_value('winhighlight', { win = win })
- if
- diff_windows[win] == nil or (current ~= WINHIGHLIGHT and current ~= CONFLICT_WINHIGHLIGHT)
- then
+ if diff_windows[win] == nil or current ~= WINHIGHLIGHT then
diff_windows[win] = current
end
- vim.api.nvim_set_option_value('winhighlight', winhighlight, { win = win })
+ vim.api.nvim_set_option_value('winhighlight', WINHIGHLIGHT, { win = win })
log.dbg('applied diff winhighlight to window %d', win)
end
end
@@ -71,7 +41,7 @@ function M.detach(diff_windows)
for win, previous in pairs(diff_windows) do
if vim.api.nvim_win_is_valid(win) then
local current = vim.api.nvim_get_option_value('winhighlight', { win = win })
- if current == WINHIGHLIGHT or current == CONFLICT_WINHIGHLIGHT then
+ if current == WINHIGHLIGHT then
vim.api.nvim_set_option_value('winhighlight', previous, { win = win })
end
end
diff --git a/lua/diffs/runtime/highlight_groups.lua b/lua/diffs/runtime/highlight_groups.lua
index a1d836dd..0bbe83dc 100644
--- a/lua/diffs/runtime/highlight_groups.lua
+++ b/lua/diffs/runtime/highlight_groups.lua
@@ -42,21 +42,15 @@ function M.apply(config, is_default)
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
- local line_nr = vim.api.nvim_get_hl(0, { name = 'LineNr' })
- local diff_added = resolve_hl('diffAdded')
- local diff_removed = resolve_hl('diffRemoved')
local dark = vim.o.background ~= 'light'
local transparent = not normal.bg
local bg = normal.bg or (dark and 0x1a1a1a or 0xf0f0f0)
local add_bg = diff_add.bg or (dark and 0x1a3a1a or 0xd0ffd0)
local del_bg = diff_delete.bg or (dark and 0x3a1a1a or 0xffd0d0)
- local add_fg = diff_added.fg or diff_add.fg or (dark and 0x80d080 or 0x206020)
- local del_fg = diff_removed.fg or diff_delete.fg or (dark and 0xd08080 or 0x802020)
local dflt = is_default or false
local normal_fg = normal.fg or (dark and 0xcccccc or 0x333333)
- local line_nr_fg = line_nr.fg or normal_fg
local alpha = config.highlights.blend_alpha or 0.6
local blended_add = blend_color(add_bg, bg, alpha)
@@ -68,43 +62,16 @@ function M.apply(config, is_default)
if not transparent then
clear_hl.bg = bg
end
- local rail_hl = { default = dflt, fg = normal_fg, nocombine = true }
- if not transparent then
- rail_hl.bg = bg
- end
vim.api.nvim_set_hl(0, 'DiffsClear', clear_hl)
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = dflt, bg = blended_add })
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = dflt, bg = blended_del })
- vim.api.nvim_set_hl(0, 'DiffsRail', rail_hl)
- vim.api.nvim_set_hl(
- 0,
- 'DiffsRailNr',
- { default = dflt, fg = line_nr_fg, bg = bg, nocombine = true }
- )
- vim.api.nvim_set_hl(
- 0,
- 'DiffsAddRailNr',
- { default = dflt, fg = add_fg, bg = blended_add, nocombine = true }
- )
- vim.api.nvim_set_hl(
- 0,
- 'DiffsDeleteRailNr',
- { default = dflt, fg = del_fg, bg = blended_del, nocombine = true }
- )
- vim.api.nvim_set_hl(0, 'DiffsAddBar', { default = dflt, fg = add_fg, bg = blended_add })
- vim.api.nvim_set_hl(0, 'DiffsDeleteBar', { default = dflt, fg = del_fg, bg = blended_del })
- vim.api.nvim_set_hl(0, 'DiffsAddCount', { default = dflt, fg = add_fg })
- vim.api.nvim_set_hl(0, 'DiffsDeleteCount', { default = dflt, fg = del_fg })
vim.api.nvim_set_hl(0, 'DiffsAddText', { default = dflt, bg = blended_add_text })
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = dflt, bg = blended_del_text })
- vim.api.nvim_set_hl(0, 'DiffsDim', { default = dflt, bg = blend_color(normal_fg, bg, 0.04) })
-
log.dbg(
- 'highlight groups: Normal.bg=%s DiffAdd.bg=#%06x diffAdded.fg=#%06x',
+ 'highlight groups: Normal.bg=%s DiffAdd.bg=#%06x',
normal.bg and string.format('#%06x', normal.bg) or 'NONE',
- add_bg,
- add_fg
+ add_bg
)
log.dbg('DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x', blended_add, blended_add_text)
log.dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text)
@@ -120,42 +87,7 @@ function M.apply(config, is_default)
)
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = dflt, bg = diff_change.bg })
vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = dflt, bg = diff_text.bg })
- vim.api.nvim_set_hl(0, 'DiffsDiffOff', {})
-
- local change_bg = diff_change.bg or 0x3a3a4a
- local text_bg = diff_text.bg or 0x4a4a5a
- local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0
-
- local base_alpha = math.max(alpha - 0.1, 0.0)
- local blended_ours = blend_color(add_bg, bg, alpha)
- local blended_theirs = blend_color(change_bg, bg, alpha)
- local blended_base = blend_color(text_bg, bg, base_alpha)
- local blended_ours_nr = add_fg
- local blended_theirs_nr = change_fg
- local blended_base_nr = change_fg
-
- vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = dflt, bg = blended_ours })
- vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = dflt, bg = blended_theirs })
- vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = dflt, bg = blended_base })
- vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = dflt, fg = 0x808080, bold = true })
- vim.api.nvim_set_hl(
- 0,
- 'DiffsConflictOursNr',
- { default = dflt, fg = blended_ours_nr, bg = blended_ours }
- )
- vim.api.nvim_set_hl(
- 0,
- 'DiffsConflictTheirsNr',
- { default = dflt, fg = blended_theirs_nr, bg = blended_theirs }
- )
- vim.api.nvim_set_hl(
- 0,
- 'DiffsConflictBaseNr',
- { default = dflt, fg = blended_base_nr, bg = blended_base }
- )
-
- -- Neogit's word-diff highlights changed words with these groups; when our
- -- integration owns the buffer we paint intra-line ourselves, so clear them.
+ -- Diffs renders intra-line highlights in Neogit buffers.
-- https://github.com/NeogitOrg/neogit/blob/99326a1310fb2d616b455d2fd16d01bf00682f06/lua/neogit/lib/diff_highlights.lua#L232-L233
if integrations.is_enabled(config.integrations.neogit) then
vim.api.nvim_set_hl(0, 'NeogitDiffAddInline', {})
diff --git a/lua/diffs/runtime/init.lua b/lua/diffs/runtime/init.lua
index 8b96048c..713b7231 100644
--- a/lua/diffs/runtime/init.lua
+++ b/lua/diffs/runtime/init.lua
@@ -1,5 +1,5 @@
---@class diffs
----@field attach fun(bufnr?: integer)
+---@field attach fun(bufnr?: integer, options?: diffs.AttachOptions)
---@field refresh fun(bufnr?: integer)
local M = {}
@@ -17,7 +17,13 @@ local ns = vim.api.nvim_create_namespace('diffs')
---@type diffs.Config
local config = config_mod.new()
+---@type table
+local attached_buffers = {}
+
local cache = cache_mod.new({
+ get_attachment = function(bufnr)
+ return attached_buffers[bufnr]
+ end,
ns = ns,
get_config = function()
return config
@@ -31,9 +37,6 @@ local hl_retry_pending = false
---@diagnostic disable-next-line: missing-fields
local fast_hl_opts = {} ---@type diffs.HunkOpts
----@type table
-local attached_buffers = {}
-
---@type table
local diff_windows = {}
@@ -109,7 +112,8 @@ function M.configure(user_config)
end
---@param bufnr? integer
-function M.attach(bufnr)
+---@param options? diffs.AttachOptions
+function M.attach(bufnr, options)
init()
attach_mod.attach({
cache = cache,
@@ -118,12 +122,19 @@ function M.attach(bufnr)
return config
end,
refresh = M.refresh,
- }, bufnr)
+ }, bufnr, options)
+end
+
+---@param bufnr? integer
+function M.detach(bufnr)
+ attach_mod.detach({ cache = cache, attached_buffers = attached_buffers }, bufnr)
end
---@param bufnr? integer
function M.refresh(bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
+ if not bufnr or bufnr == 0 then
+ bufnr = vim.api.nvim_get_current_buf()
+ end
cache:invalidate(bufnr)
end
@@ -148,48 +159,6 @@ function M.detach_diff()
diff_windows_mod.detach(diff_windows)
end
----@return boolean
-function M.get_fugitive_config()
- init()
- return config.integrations.fugitive == true
-end
-
----@return boolean
-function M.get_neojj_config()
- init()
- return config.integrations.neojj == true
-end
-
----@return boolean
-function M.get_committia_config()
- init()
- return config.integrations.committia == true
-end
-
----@return boolean
-function M.get_telescope_config()
- init()
- return config.integrations.telescope == true
-end
-
----@return boolean
-function M.get_conflict_config()
- init()
- return config.conflict
-end
-
----@return diffs.ViewConfig
-function M.get_view_config()
- init()
- return config.view
-end
-
----@return boolean|diffs.DifftasticConfig|nil
-function M.get_difftastic_config()
- init()
- return config.integrations.difftastic
-end
-
---@return diffs.HunkOpts
function M.get_highlight_opts()
init()
diff --git a/lua/diffs/spec.lua b/lua/diffs/spec.lua
deleted file mode 100644
index de278eef..00000000
--- a/lua/diffs/spec.lua
+++ /dev/null
@@ -1,287 +0,0 @@
-local M = {}
-
-M.endpoint_kind = {
- tree = 'tree',
- index = 'index',
- worktree = 'worktree',
- stage = 'stage',
-}
-
-M.scope_kind = {
- file = 'file',
-}
-
-local function require_table(value, name)
- if type(value) ~= 'table' then
- error('diffs: ' .. name .. ' must be a table')
- end
-end
-
-local function require_non_empty_string(value, name)
- if type(value) ~= 'string' or value == '' then
- error('diffs: ' .. name .. ' must be a non-empty string')
- end
-end
-
----@class diffs.TreeEndpoint
----@field kind "tree"
----@field rev string
-
----@class diffs.IndexEndpoint
----@field kind "index"
-
----@class diffs.WorktreeEndpoint
----@field kind "worktree"
-
----@class diffs.StageEndpoint
----@field kind "stage"
----@field stage 1|2|3
-
----@alias diffs.Endpoint diffs.TreeEndpoint|diffs.IndexEndpoint|diffs.WorktreeEndpoint|diffs.StageEndpoint
-
----@class diffs.FileScope
----@field kind "file"
----@field path string
-
----@alias diffs.Scope diffs.FileScope
-
----@class diffs.DiffSpec
----@field left diffs.Endpoint
----@field right diffs.Endpoint
----@field scope diffs.Scope
----@field mode "unified"
-
----@param rev string
----@return diffs.TreeEndpoint
-function M.tree(rev)
- require_non_empty_string(rev, 'tree endpoint rev')
- return { kind = M.endpoint_kind.tree, rev = rev }
-end
-
-M.revision = M.tree
-
----@return diffs.IndexEndpoint
-function M.index()
- return { kind = M.endpoint_kind.index }
-end
-
----@return diffs.WorktreeEndpoint
-function M.worktree()
- return { kind = M.endpoint_kind.worktree }
-end
-
----@param stage 1|2|3
----@return diffs.StageEndpoint
-function M.stage(stage)
- if stage ~= 1 and stage ~= 2 and stage ~= 3 then
- error('diffs: stage endpoint must be 1, 2, or 3')
- end
- return { kind = M.endpoint_kind.stage, stage = stage }
-end
-
----@param endpoint diffs.Endpoint
----@return diffs.Endpoint
-function M.endpoint(endpoint)
- require_table(endpoint, 'endpoint')
-
- if endpoint.kind == M.endpoint_kind.tree then
- return M.tree(endpoint.rev)
- end
-
- if endpoint.kind == M.endpoint_kind.index then
- return M.index()
- end
-
- if endpoint.kind == M.endpoint_kind.worktree then
- return M.worktree()
- end
-
- if endpoint.kind == M.endpoint_kind.stage then
- return M.stage(endpoint.stage)
- end
-
- error('diffs: unsupported endpoint kind: ' .. tostring(endpoint.kind))
-end
-
----@param path string
----@return diffs.FileScope
-function M.file_scope(path)
- require_non_empty_string(path, 'file scope path')
- return { kind = M.scope_kind.file, path = path }
-end
-
----@param scope diffs.Scope
----@return diffs.Scope
-function M.scope(scope)
- require_table(scope, 'scope')
-
- if scope.kind == M.scope_kind.file then
- return M.file_scope(scope.path)
- end
-
- error('diffs: unsupported scope kind: ' .. tostring(scope.kind))
-end
-
----@param opts diffs.DiffSpec
----@return diffs.DiffSpec
-function M.new(opts)
- require_table(opts, 'diff spec')
-
- local mode = opts.mode or 'unified'
- if mode ~= 'unified' then
- error('diffs: diff spec mode must be "unified"')
- end
-
- return {
- left = M.endpoint(opts.left),
- right = M.endpoint(opts.right),
- scope = M.scope(opts.scope),
- mode = mode,
- }
-end
-
----@param left diffs.Endpoint
----@param right diffs.Endpoint
----@param path string
----@return diffs.DiffSpec
-function M.file(left, right, path)
- return M.new({
- left = left,
- right = right,
- scope = M.file_scope(path),
- mode = 'unified',
- })
-end
-
----@param path string
----@return diffs.DiffSpec
-function M.index_to_worktree(path)
- return M.file(M.index(), M.worktree(), path)
-end
-
----@param path string
----@return diffs.DiffSpec
-function M.head_to_index(path)
- return M.file(M.tree('HEAD'), M.index(), path)
-end
-
----@param rev string
----@param path string
----@return diffs.DiffSpec
-function M.rev_to_worktree(rev, path)
- return M.file(M.tree(rev), M.worktree(), path)
-end
-
----@param left_rev string
----@param right_rev string
----@param path string
----@return diffs.DiffSpec
-function M.rev_to_rev(left_rev, right_rev, path)
- return M.file(M.tree(left_rev), M.tree(right_rev), path)
-end
-
----@param stage 1|2|3
----@param path string
----@return diffs.DiffSpec
-function M.stage_to_worktree(stage, path)
- return M.file(M.stage(stage), M.worktree(), path)
-end
-
----@param endpoint diffs.Endpoint
----@return string
-function M.endpoint_label(endpoint)
- endpoint = M.endpoint(endpoint)
-
- if endpoint.kind == M.endpoint_kind.tree then
- return 'tree:' .. endpoint.rev
- end
-
- if endpoint.kind == M.endpoint_kind.stage then
- return 'stage:' .. endpoint.stage
- end
-
- return endpoint.kind
-end
-
----@param scope diffs.Scope
----@return string
-function M.scope_label(scope)
- scope = M.scope(scope)
- return scope.kind .. ':' .. scope.path
-end
-
----@param diff_spec diffs.DiffSpec
----@return string
-function M.label(diff_spec)
- diff_spec = M.new(diff_spec)
- return M.endpoint_label(diff_spec.left)
- .. ' -> '
- .. M.endpoint_label(diff_spec.right)
- .. ' '
- .. M.scope_label(diff_spec.scope)
-end
-
----@param diff_spec diffs.DiffSpec
----@return "index"|"worktree"|nil
-function M.mutation_target(diff_spec)
- diff_spec = M.new(diff_spec)
-
- -- Merge-stage comparisons are read-only: a stage blob cannot be staged or
- -- restored through hunk actions.
- if diff_spec.left.kind == M.endpoint_kind.stage then
- return nil
- end
-
- if diff_spec.right.kind == M.endpoint_kind.index then
- return M.endpoint_kind.index
- end
-
- if diff_spec.right.kind == M.endpoint_kind.worktree then
- return M.endpoint_kind.worktree
- end
-
- return nil
-end
-
----@param diff_spec diffs.DiffSpec
----@return { can_put: boolean, can_obtain: boolean }
-function M.patch_actions(diff_spec)
- diff_spec = M.new(diff_spec)
-
- return {
- can_put = diff_spec.left.kind == M.endpoint_kind.index
- and diff_spec.right.kind == M.endpoint_kind.worktree,
- can_obtain = diff_spec.left.kind == M.endpoint_kind.tree
- and diff_spec.right.kind == M.endpoint_kind.index,
- }
-end
-
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_index_target(diff_spec)
- return M.mutation_target(diff_spec) == M.endpoint_kind.index
-end
-
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_worktree_target(diff_spec)
- return M.mutation_target(diff_spec) == M.endpoint_kind.worktree
-end
-
----@param diff_spec diffs.DiffSpec
----@return boolean
-function M.is_read_only(diff_spec)
- return M.mutation_target(diff_spec) == nil
-end
-
----@param diff_spec diffs.DiffSpec
----@return { read_only: boolean, target: "index"|"worktree"|nil }
-function M.mutability(diff_spec)
- local target = M.mutation_target(diff_spec)
- return {
- read_only = target == nil,
- target = target,
- }
-end
-
-return M
diff --git a/lua/diffs/split.lua b/lua/diffs/split.lua
deleted file mode 100644
index 7f8e32c1..00000000
--- a/lua/diffs/split.lua
+++ /dev/null
@@ -1,1460 +0,0 @@
-local M = {}
-
-local diffopt = require('diffs.diffopt')
-
-local diff = require('diffs.diff')
-local diffspec = require('diffs.spec')
-local difftastic = require('diffs.difftastic')
-local generated = require('diffs.generated')
-local hunk_model = require('diffs.hunks')
-local lists = require('diffs.lists')
-local log = require('diffs.log')
-local render = require('diffs.render')
-
-local notify = log.notify
-
-local split_align = require('diffs.split_align')
-
----@type table
-local closing_buffers = {}
----@type table
-local cleanup_autocmds = {}
----@type table
-local peer_buffers = {}
----@type table>
-local pair_window_options = {}
----@type table
-local split_windows = {}
-local win_closed_registered = false
-
----@class diffs.SplitPaneInfo
----@field rows diffs.SplitRow[]
----@field side "left"|"right"
----@field rail_width integer
----@field anchors integer[]
----@field difft_intra? table
-
----@type table
-local pane_info = {}
-local split_line_ns = vim.api.nvim_create_namespace('diffs_split_line')
-local split_intra_ns = vim.api.nvim_create_namespace('diffs_split_intra')
-local split_statuscolumn = "%!v:lua.require'diffs.split'.statuscolumn()"
-
----@class diffs.SplitReuseWins
----@field left integer
----@field right integer
-
----@class diffs.SplitOpenOpts
----@field spec diffs.DiffSpec
----@field repo_root string
----@field filetype? string
----@field worktree_lines? diffs.ContentLines|string[]
----@field worktree_buf? integer
----@field diff_lines? string[]
----@field hunk_index? integer
----@field quickfix? boolean
----@field title? string
----@field reuse_wins? diffs.SplitReuseWins
-
----@class diffs.SplitEndpointSource
----@field version integer
----@field kind "split_endpoint"
----@field worktree_buf? integer
----@field repo_root string
----@field spec diffs.DiffSpec
----@field side "left"|"right"
----@field path string
----@field filetype? string
----@field quickfix? boolean
-
----@param repo_root string
----@param path string
----@return string
-local function abs_path(repo_root, path)
- return repo_root .. '/' .. path
-end
-
----@param endpoint diffs.Endpoint
----@return string
-local function endpoint_name(endpoint)
- endpoint = diffspec.endpoint(endpoint)
- if endpoint.kind == diffspec.endpoint_kind.tree then
- return endpoint.rev
- end
- return endpoint.kind
-end
-
----@param diff_spec diffs.DiffSpec
----@param side "left"|"right"
----@return diffs.Endpoint
-local function side_endpoint(diff_spec, side)
- diff_spec = diffspec.new(diff_spec)
- return side == 'left' and diff_spec.left or diff_spec.right
-end
-
----@param lines string[]
----@return string[]
-local function plain_lines(lines)
- local result = {}
- for i, line in ipairs(lines) do
- result[i] = line
- end
- return result
-end
-
----@param source diffs.SplitEndpointSource
----@param opts? { worktree_lines?: diffs.ContentLines|string[] }
----@return string[]?, string?
-local function endpoint_lines(source, opts)
- opts = opts or {}
- local spec = diffspec.new(source.spec)
- local lines, err = render.read_endpoint(
- side_endpoint(spec, source.side),
- abs_path(source.repo_root, source.path),
- {
- worktree_lines = opts.worktree_lines,
- worktree_buf = source.worktree_buf,
- empty_on_missing = true,
- }
- )
- if not lines then
- return nil, err or ('could not read ' .. source.side .. ' endpoint')
- end
- return plain_lines(lines), nil
-end
-
----@param diff_lines string[]?
----@param spec diffs.DiffSpec
----@return diffs.DiffHunk[]
-local function split_hunks_for(diff_lines, spec)
- if not diff_lines then
- return {}
- end
- return hunk_model.parse(diff_lines, spec)
-end
-
----@class diffs.SplitSideConfig
----@field kind "delete"|"add"
----@field lnum "old_lnum"|"new_lnum"
----@field spans "del_spans"|"add_spans"
----@field line_hl string
----@field text_hl string
----@field bar_hl string
----@field nr_hl string
-
----@type table<"left"|"right", diffs.SplitSideConfig>
-local side_config = {
- left = {
- kind = 'delete',
- lnum = 'old_lnum',
- spans = 'del_spans',
- line_hl = 'DiffsDelete',
- text_hl = 'DiffsDeleteText',
- bar_hl = 'DiffsDeleteBar',
- nr_hl = 'DiffsDeleteRailNr',
- },
- right = {
- kind = 'add',
- lnum = 'new_lnum',
- spans = 'add_spans',
- line_hl = 'DiffsAdd',
- text_hl = 'DiffsAddText',
- bar_hl = 'DiffsAddBar',
- nr_hl = 'DiffsAddRailNr',
- },
-}
-
----@param source integer? diff-mapped source line number for this row
----@param width integer
----@param number boolean
----@return string
-local function rail_number(source, width, number)
- if not number then
- return ''
- end
- if not source then
- return string.rep(' ', width)
- end
- return ('%' .. width .. 'd'):format(source)
-end
-
----@param info diffs.SplitPaneInfo
----@param lnum integer
----@param number boolean
----@return string
-local function rail_segment(info, lnum, number)
- local width = info.rail_width
- local row = info.rows[lnum]
- if not row or row.kind == 'filler' then
- local pad = number and width + 2 or 2
- return '%C%s%#DiffsRail#' .. string.rep(' ', pad)
- end
- local cfg = side_config[info.side]
- local numstr = rail_number(row[cfg.lnum], width, number)
- if row.kind == cfg.kind then
- return ('%%C%%s%%#%s#%s%%#%s#%s%%#%s# '):format(
- cfg.bar_hl,
- '▏',
- cfg.nr_hl,
- numstr,
- cfg.line_hl
- )
- end
- return ('%%C%%s%%#DiffsRailNr# %s '):format(numstr)
-end
-
----@return string
-function M.statuscolumn()
- local win = vim.g.statusline_winid
- if not win or win == 0 then
- return ''
- end
- local ok, bufnr = pcall(vim.api.nvim_win_get_buf, win)
- if not ok then
- return ''
- end
- local info = pane_info[bufnr]
- if not info then
- return ''
- end
- local number = vim.api.nvim_get_option_value('number', { win = win })
- local rendered_ok, rendered = pcall(rail_segment, info, vim.v.lnum, number)
- return rendered_ok and rendered or ''
-end
-
----@param bufnr integer
----@param info diffs.SplitPaneInfo
-local function set_split_line_bg(bufnr, info)
- vim.api.nvim_buf_clear_namespace(bufnr, split_line_ns, 0, -1)
- local priorities = require('diffs.runtime').get_highlight_opts().highlights.priorities
- local cfg = side_config[info.side]
- for lnum, row in ipairs(info.rows) do
- if row.kind == cfg.kind then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, split_line_ns, lnum - 1, 0, {
- end_row = lnum,
- end_col = 0,
- hl_group = cfg.line_hl,
- hl_eol = true,
- priority = priorities.line_bg,
- })
- end
- end
-end
-
----@param bufnr integer
----@param info diffs.SplitPaneInfo
-local function set_split_intra_difft(bufnr, info)
- local opts = require('diffs.runtime').get_highlight_opts()
- if diffopt.inline() == 'none' then
- return
- end
- local cfg = side_config[info.side]
- local priority = opts.highlights.priorities.char_bg
- local line_count = vim.api.nvim_buf_line_count(bufnr)
- for row_index, spans in pairs(info.difft_intra) do
- if row_index >= 1 and row_index <= line_count then
- for _, span in ipairs(spans) do
- pcall(vim.api.nvim_buf_set_extmark, bufnr, split_intra_ns, row_index - 1, span.col_start, {
- end_col = span.col_end,
- hl_group = cfg.text_hl,
- priority = priority,
- })
- end
- end
- end
-end
-
----@param bufnr integer
----@param info diffs.SplitPaneInfo
----@param hunks diffs.DiffHunk[]
-local function set_split_intra(bufnr, info, hunks)
- vim.api.nvim_buf_clear_namespace(bufnr, split_intra_ns, 0, -1)
- if info.difft_intra then
- return set_split_intra_difft(bufnr, info)
- end
- if not hunks or #hunks == 0 then
- return
- end
-
- local opts = require('diffs.runtime').get_highlight_opts()
- local intra_cfg = opts.highlights.intra
- if diffopt.inline() == 'none' then
- return
- end
-
- local cfg = side_config[info.side]
- local priority = opts.highlights.priorities.char_bg
-
- ---@type table
- local lnum_to_row = {}
- for row_index, row in ipairs(info.rows) do
- local source_lnum = row[cfg.lnum]
- if source_lnum then
- lnum_to_row[source_lnum] = row_index
- end
- end
- local line_count = vim.api.nvim_buf_line_count(bufnr)
-
- for _, hunk in ipairs(hunks) do
- ---@type string[]
- local texts = {}
- ---@type diffs.DiffHunkLine[]
- local refs = {}
- for _, line in ipairs(hunk.lines or {}) do
- if line.kind ~= 'header' then
- texts[#texts + 1] = line.text
- refs[#refs + 1] = line
- end
- end
-
- if #texts > 0 and #texts <= intra_cfg.max_lines then
- local intra = diff.compute_intra_hunks(texts)
- local spans = intra and intra[cfg.spans] or {}
- for _, span in ipairs(spans) do
- local ref = refs[span.line]
- local source_lnum = ref and ref.kind == cfg.kind and ref[cfg.lnum]
- local buf_row = source_lnum and lnum_to_row[source_lnum]
- local col_start = span.col_start - 1
- local col_end = span.col_end - 1
- if
- buf_row
- and buf_row >= 1
- and buf_row <= line_count
- and col_start >= 0
- and col_end > col_start
- then
- pcall(vim.api.nvim_buf_set_extmark, bufnr, split_intra_ns, buf_row - 1, col_start, {
- end_col = col_end,
- hl_group = cfg.text_hl,
- priority = priority,
- })
- end
- end
- end
- end
-end
-
----@param bufnr integer
----@param filetype? string
-local function set_buffer_options(bufnr, filetype)
- vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
- vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr })
- vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
- vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
- if filetype and filetype ~= '' then
- vim.api.nvim_set_option_value('filetype', filetype, { buf = bufnr })
- end
-end
-
----@param bufnr integer
----@param mode string
----@param lhs string
----@return table?
-local function get_buffer_keymap(bufnr, mode, lhs)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode)) do
- if keymap.lhs == lhs then
- return keymap
- end
- end
- return nil
-end
-
----@param bufnr integer
----@param lhs string
----@param desc string
-local function clear_owned_keymap(bufnr, lhs, desc)
- local keymap = get_buffer_keymap(bufnr, 'n', lhs)
- if keymap and keymap.desc == desc then
- pcall(vim.keymap.del, 'n', lhs, { buffer = bufnr })
- end
-end
-
----@param bufnr integer
-local function clear_owned_split_keymaps(bufnr)
- clear_owned_keymap(bufnr, '', 'Refresh diff and redraw')
- clear_owned_keymap(bufnr, 'q', 'Close split diff')
- clear_owned_keymap(bufnr, '', 'Open source file')
- clear_owned_keymap(bufnr, ']c', 'Next diff hunk')
- clear_owned_keymap(bufnr, '[c', 'Previous diff hunk')
-end
-
----@param bufnr integer
-local function set_keymaps(bufnr)
- if not get_buffer_keymap(bufnr, 'n', '') then
- vim.keymap.set(
- 'n',
- '',
- 'nohlsearchdiffupdate(diffs-refresh)',
- { buffer = bufnr, silent = true, desc = 'Refresh diff and redraw' }
- )
- end
- if not get_buffer_keymap(bufnr, 'n', 'q') then
- vim.keymap.set('n', 'q', function()
- M.close_pair(vim.api.nvim_get_current_buf())
- end, { buffer = bufnr, desc = 'Close split diff' })
- end
- if not get_buffer_keymap(bufnr, 'n', '') then
- vim.keymap.set('n', '', function()
- M.open_source(vim.api.nvim_get_current_buf())
- end, { buffer = bufnr, desc = 'Open source file' })
- end
- if not get_buffer_keymap(bufnr, 'n', ']c') then
- vim.keymap.set('n', ']c', function()
- M.goto_next(vim.api.nvim_get_current_buf())
- end, { buffer = bufnr, desc = 'Next diff hunk' })
- end
- if not get_buffer_keymap(bufnr, 'n', '[c') then
- vim.keymap.set('n', '[c', function()
- M.goto_prev(vim.api.nvim_get_current_buf())
- end, { buffer = bufnr, desc = 'Previous diff hunk' })
- end
-end
-
----@param source diffs.SplitEndpointSource
----@return string
-local function buffer_name(source)
- local spec = diffspec.new(source.spec)
- local endpoint = side_endpoint(spec, source.side)
- return 'diffs://split:' .. source.side .. ':' .. endpoint_name(endpoint) .. ':' .. source.path
-end
-
----@param bufnr integer
----@param source diffs.SplitEndpointSource
----@param info diffs.SplitPaneInfo
----@param hunks diffs.DiffHunk[]
-local function paint_pane(bufnr, source, info, hunks)
- pane_info[bufnr] = info
- generated.set_source(bufnr, source)
- if info.difft_intra then
- difftastic.mark_active(bufnr)
- end
- set_split_line_bg(bufnr, info)
- set_split_intra(bufnr, info, hunks)
-end
-
----@param source diffs.SplitEndpointSource
----@param lines string[]
----@param info diffs.SplitPaneInfo
----@param hunks diffs.DiffHunk[]
----@return integer
-local function create_buffer(source, lines, info, hunks)
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- vim.api.nvim_buf_set_name(bufnr, buffer_name(source))
- paint_pane(bufnr, source, info, hunks)
- set_buffer_options(bufnr, source.filetype)
- set_keymaps(bufnr)
- return bufnr
-end
-
----@param win integer
-local function remember_pair_window_options(win)
- if pair_window_options[win] or not vim.api.nvim_win_is_valid(win) then
- return
- end
-
- pair_window_options[win] = {
- scrollbind = vim.api.nvim_get_option_value('scrollbind', { win = win }),
- cursorbind = vim.api.nvim_get_option_value('cursorbind', { win = win }),
- wrap = vim.api.nvim_get_option_value('wrap', { win = win }),
- foldmethod = vim.api.nvim_get_option_value('foldmethod', { win = win }),
- foldenable = vim.api.nvim_get_option_value('foldenable', { win = win }),
- statuscolumn = vim.api.nvim_get_option_value('statuscolumn', { win = win }),
- winhighlight = vim.api.nvim_get_option_value('winhighlight', { win = win }),
- }
-end
-
----@param win integer
-local function set_pair_window_options(win)
- remember_pair_window_options(win)
- vim.api.nvim_set_option_value('scrollbind', true, { win = win, scope = 'local' })
- vim.api.nvim_set_option_value('cursorbind', true, { win = win, scope = 'local' })
- vim.api.nvim_set_option_value('wrap', false, { win = win, scope = 'local' })
- vim.api.nvim_set_option_value('statuscolumn', split_statuscolumn, { win = win, scope = 'local' })
- vim.api.nvim_set_option_value('foldmethod', 'manual', { win = win, scope = 'local' })
- vim.api.nvim_set_option_value('foldenable', false, { win = win, scope = 'local' })
-end
-
----@param win integer
-local function clear_pair_window_options(win)
- local saved = pair_window_options[win]
- if saved then
- pair_window_options[win] = nil
- for name, value in pairs(saved) do
- pcall(vim.api.nvim_set_option_value, name, value, { win = win, scope = 'local' })
- end
- return
- end
-
- pcall(vim.api.nvim_set_option_value, 'scrollbind', false, { win = win, scope = 'local' })
- pcall(vim.api.nvim_set_option_value, 'cursorbind', false, { win = win, scope = 'local' })
- pcall(vim.api.nvim_set_option_value, 'foldenable', true, { win = win, scope = 'local' })
- pcall(vim.api.nvim_set_option_value, 'statuscolumn', '', { win = win, scope = 'local' })
-end
-
----@param bufnr integer
----@return integer?
-local function stored_split_peer(bufnr)
- local ok, peer = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_split_peer')
- if ok and type(peer) == 'number' then
- return peer
- end
- peer = peer_buffers[bufnr]
- if type(peer) == 'number' then
- return peer
- end
- return nil
-end
-
----@param bufnr integer
----@return integer?
-local function split_peer(bufnr)
- local peer = stored_split_peer(bufnr)
- if peer and vim.api.nvim_buf_is_valid(peer) then
- return peer
- end
- return nil
-end
-
----@type fun(source: table): diffs.SplitEndpointSource
-local normalize_source
-
----@param bufnr integer
----@return diffs.SplitEndpointSource?
-local function buffer_source(bufnr)
- local ok, source = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_source')
- if ok and type(source) == 'table' and source.kind == 'split_endpoint' then
- return normalize_source(source)
- end
- return nil
-end
-
----@param bufnr integer
-local function ensure_pair_cleanup(bufnr)
- if cleanup_autocmds[bufnr] then
- return
- end
-
- cleanup_autocmds[bufnr] = vim.api.nvim_create_autocmd('BufWipeout', {
- buffer = bufnr,
- once = true,
- callback = function()
- local peer = peer_buffers[bufnr] or split_peer(bufnr)
- cleanup_autocmds[bufnr] = nil
- peer_buffers[bufnr] = nil
- if closing_buffers[bufnr] or not peer or vim.v.exiting ~= vim.NIL then
- return
- end
- vim.schedule(function()
- if vim.api.nvim_buf_is_valid(peer) then
- M.close_pair(peer)
- end
- end)
- end,
- })
-end
-
-local function register_win_closed()
- if win_closed_registered then
- return
- end
- win_closed_registered = true
- vim.api.nvim_create_autocmd('WinClosed', {
- callback = function(args)
- local win = tonumber(args.match)
- if not win then
- return
- end
- local bufnr = split_windows[win]
- split_windows[win] = nil
- if not bufnr or vim.v.exiting ~= vim.NIL then
- return
- end
- if closing_buffers[bufnr] or not vim.api.nvim_buf_is_valid(bufnr) then
- return
- end
- vim.schedule(function()
- if vim.api.nvim_buf_is_valid(bufnr) and not closing_buffers[bufnr] then
- M.close_pair(bufnr)
- end
- end)
- end,
- })
-end
-
----@param win integer
----@param bufnr integer
-local function track_split_window(win, bufnr)
- register_win_closed()
- split_windows[win] = bufnr
-end
-
----@param left_buf integer
----@param right_buf integer
-local function set_peers(left_buf, right_buf)
- vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf)
- vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf)
- peer_buffers[left_buf] = right_buf
- peer_buffers[right_buf] = left_buf
- ensure_pair_cleanup(left_buf)
- ensure_pair_cleanup(right_buf)
-end
-
----@param bufnr integer
----@param keep_closing? boolean
-local function clear_pair_tracking(bufnr, keep_closing)
- if cleanup_autocmds[bufnr] then
- pcall(vim.api.nvim_del_autocmd, cleanup_autocmds[bufnr])
- cleanup_autocmds[bufnr] = nil
- end
- peer_buffers[bufnr] = nil
- pane_info[bufnr] = nil
- for win, win_buf in pairs(split_windows) do
- if win_buf == bufnr then
- split_windows[win] = nil
- end
- end
- if not keep_closing then
- closing_buffers[bufnr] = nil
- end
-end
-
----@param bufnr integer
-local function clear_split_state(bufnr)
- if not vim.api.nvim_buf_is_valid(bufnr) then
- return
- end
-
- clear_pair_tracking(bufnr)
- pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_split_peer')
- vim.api.nvim_buf_clear_namespace(bufnr, split_line_ns, 0, -1)
- vim.api.nvim_buf_clear_namespace(bufnr, split_intra_ns, 0, -1)
- clear_owned_split_keymaps(bufnr)
-end
-
----@param bufnr integer
-local function detach_split_endpoint(bufnr)
- local peer = stored_split_peer(bufnr)
- clear_split_state(bufnr)
- if peer and peer ~= bufnr and vim.api.nvim_buf_is_valid(peer) then
- clear_split_state(peer)
- end
-end
-
----@param bufnr integer
----@return integer[]
-local function windows_for_buffer(bufnr)
- local wins = {}
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == bufnr then
- wins[#wins + 1] = win
- end
- end
- return wins
-end
-
----@param bufnr integer
----@return integer?
-local function first_window_for_buffer(bufnr)
- return windows_for_buffer(bufnr)[1]
-end
-
----@param bufnr integer
----@return integer?
-local function current_or_first_window_for_buffer(bufnr)
- local current_win = vim.api.nvim_get_current_win()
- if vim.api.nvim_win_is_valid(current_win) and vim.api.nvim_win_get_buf(current_win) == bufnr then
- return current_win
- end
- return first_window_for_buffer(bufnr)
-end
-
----@param bufnr integer
----@param lnum integer
----@return integer
-local function clamp_lnum(bufnr, lnum)
- return math.max(1, math.min(lnum, vim.api.nvim_buf_line_count(bufnr)))
-end
-
----@param anchors integer[]
----@return integer[]
-local function sorted_anchors(anchors)
- local rows = {}
- for _, row in pairs(anchors or {}) do
- rows[#rows + 1] = row
- end
- table.sort(rows)
- return rows
-end
-
----@param win integer
----@param lnum integer
-local function set_window_lnum(win, lnum)
- if not vim.api.nvim_win_is_valid(win) then
- return
- end
- local bufnr = vim.api.nvim_win_get_buf(win)
- vim.api.nvim_win_set_cursor(win, { clamp_lnum(bufnr, lnum), 0 })
-end
-
----@param bufnr integer
----@param row integer
-local function move_pair_to_row(bufnr, row)
- for _, win in ipairs(windows_for_buffer(bufnr)) do
- set_window_lnum(win, row)
- end
- local peer = split_peer(bufnr)
- if peer then
- for _, win in ipairs(windows_for_buffer(peer)) do
- set_window_lnum(win, row)
- end
- end
-end
-
----@param bufnr integer
----@param direction integer
-local function goto_anchor(bufnr, direction)
- local info = pane_info[bufnr]
- if not info then
- return
- end
- local anchors = sorted_anchors(info.anchors)
- if #anchors == 0 then
- return
- end
- local win = current_or_first_window_for_buffer(bufnr) or vim.api.nvim_get_current_win()
- local row = vim.api.nvim_win_get_cursor(win)[1]
- local target
- if direction > 0 then
- for _, anchor in ipairs(anchors) do
- if anchor > row then
- target = anchor
- break
- end
- end
- if not target then
- target = anchors[1]
- notify('wrapped to first hunk', vim.log.levels.INFO)
- end
- else
- for i = #anchors, 1, -1 do
- if anchors[i] < row then
- target = anchors[i]
- break
- end
- end
- if not target then
- target = anchors[#anchors]
- notify('wrapped to last hunk', vim.log.levels.INFO)
- end
- end
- move_pair_to_row(bufnr, target)
-end
-
----@param bufnr integer
----@param source diffs.SplitEndpointSource
----@param lines string[]
----@param info diffs.SplitPaneInfo
----@param hunks diffs.DiffHunk[]
-local function apply_buffer_lines(bufnr, source, lines, info, hunks)
- vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- paint_pane(bufnr, source, info, hunks)
- set_buffer_options(bufnr, source.filetype)
- set_keymaps(bufnr)
- ensure_pair_cleanup(bufnr)
-end
-
----@param bufnr integer
-local function refresh_visible_pair_options(bufnr)
- for _, buf in ipairs({ bufnr, split_peer(bufnr) }) do
- if buf and vim.api.nvim_buf_is_valid(buf) then
- for _, win in ipairs(windows_for_buffer(buf)) do
- set_pair_window_options(win)
- end
- end
- end
-end
-
----@param source diffs.SplitEndpointSource
-local function delete_existing_buffer(source)
- local existing = vim.fn.bufnr(buffer_name(source))
- if existing == -1 or not vim.api.nvim_buf_is_valid(existing) then
- return
- end
-
- M.close_pair(existing)
- if vim.api.nvim_buf_is_valid(existing) then
- pcall(vim.api.nvim_buf_delete, existing, { force = true })
- end
-end
-
----@param left_source diffs.SplitEndpointSource
----@param right_source diffs.SplitEndpointSource
-local function delete_existing_pair_buffers(left_source, right_source)
- delete_existing_buffer(left_source)
- delete_existing_buffer(right_source)
-end
-
----@param filepath string
----@return integer?
-local function find_window_for_file(filepath)
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_is_valid(win) then
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_buf_get_name(buf) == filepath then
- return win
- end
- end
- end
- return nil
-end
-
----@param repo_root string
----@param path string
----@return string
-local function resolve_worktree_path(repo_root, path)
- if path:sub(1, 1) == '/' then
- return path
- end
- return abs_path(repo_root, path)
-end
-
----@param buffers table
----@param bufnr integer?
-local function add_valid_buffer(buffers, bufnr)
- if type(bufnr) == 'number' and vim.api.nvim_buf_is_valid(bufnr) then
- buffers[bufnr] = true
- end
-end
-
----@param win integer
----@param buffers table
----@return boolean
-local function window_has_buffer(win, buffers)
- return buffers[vim.api.nvim_win_get_buf(win)] == true
-end
-
----@param bufnr integer
----@return table
-local function pair_buffers_for(bufnr)
- local buffers = {}
- add_valid_buffer(buffers, bufnr)
- add_valid_buffer(buffers, split_peer(bufnr))
- return buffers
-end
-
----@param buffers table
----@return integer[], integer[]
-local function tab_windows_for_buffers(buffers)
- local pair_wins = {}
- local non_pair_wins = {}
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_is_valid(win) then
- if window_has_buffer(win, buffers) then
- pair_wins[#pair_wins + 1] = win
- else
- non_pair_wins[#non_pair_wins + 1] = win
- end
- end
- end
- return pair_wins, non_pair_wins
-end
-
----@param bufnr integer
----@param keep_win integer
----@return table?
-local function close_pair_into_window(bufnr, keep_win)
- if not vim.api.nvim_win_is_valid(keep_win) then
- return nil
- end
-
- local buffers = pair_buffers_for(bufnr)
- if vim.tbl_isempty(buffers) then
- return nil
- end
-
- local keep_is_pair_win = false
- local pair_wins = tab_windows_for_buffers(buffers)
- for _, win in ipairs(pair_wins) do
- keep_is_pair_win = keep_is_pair_win or win == keep_win
- end
- if not keep_is_pair_win then
- return nil
- end
-
- for buf in pairs(buffers) do
- closing_buffers[buf] = true
- end
-
- for _, win in ipairs(pair_wins) do
- clear_pair_window_options(win)
- end
- vim.api.nvim_set_current_win(keep_win)
- for _, win in ipairs(pair_wins) do
- if win ~= keep_win and vim.api.nvim_win_is_valid(win) then
- pcall(vim.api.nvim_win_close, win, true)
- end
- end
-
- return buffers
-end
-
----@param buffers table
-local function delete_pair_buffers(buffers)
- for buf in pairs(buffers) do
- clear_pair_tracking(buf, true)
- if vim.api.nvim_buf_is_valid(buf) then
- pcall(vim.api.nvim_buf_delete, buf, { force = true })
- end
- closing_buffers[buf] = nil
- end
-end
-
----@param spec diffs.DiffSpec
----@param old_lines string[]
----@param new_lines string[]
----@return diffs.DifftAlignment? difftastic alignment when enabled/available, else nil
-local function difft_alignment(spec, old_lines, new_lines)
- if not difftastic.available() then
- return nil
- end
- local relpath = (spec and spec.scope and spec.scope.path) or 'file'
- return (difftastic.align(old_lines, new_lines, relpath))
-end
-
----@param old_lines string[]
----@param new_lines string[]
----@param hunks diffs.DiffHunk[]
----@param difft? diffs.DifftAlignment precomputed difftastic alignment
----@return diffs.SplitAlignment, diffs.SplitPaneInfo, diffs.SplitPaneInfo
-local function build_pane_infos(old_lines, new_lines, hunks, difft)
- local use_difft = difft ~= nil and difft.changed
- local alignment = use_difft and difft or split_align.align(old_lines, new_lines, hunks)
- local max_lnum = math.max(#old_lines, #new_lines, 1)
- local rail_width = math.max(2, #tostring(max_lnum))
- local left_info = {
- rows = alignment.left_rows,
- side = 'left',
- rail_width = rail_width,
- anchors = alignment.anchors,
- difft_intra = use_difft and difft.left_intra or nil,
- }
- local right_info = {
- rows = alignment.right_rows,
- side = 'right',
- rail_width = rail_width,
- anchors = alignment.anchors,
- difft_intra = use_difft and difft.right_intra or nil,
- }
- return alignment, left_info, right_info
-end
-
----@param opts diffs.SplitOpenOpts
----@return { left_buf: integer, right_buf: integer, left_win: integer, right_win: integer }?, string?
-function M.open(opts)
- local spec = diffspec.new(opts.spec)
- local filetype = opts.filetype
- local path = spec.scope.path
- local base_source = {
- repo_root = opts.repo_root,
- spec = spec,
- path = path,
- filetype = filetype,
- quickfix = opts.quickfix,
- worktree_buf = opts.worktree_buf,
- }
-
- local left_source =
- generated.split_endpoint_source(vim.tbl_extend('force', base_source, { side = 'left' }))
- local right_source =
- generated.split_endpoint_source(vim.tbl_extend('force', base_source, { side = 'right' }))
- local old_content, left_err =
- endpoint_lines(left_source, { worktree_lines = opts.worktree_lines })
- if not old_content then
- return nil, left_err
- end
- local new_content, right_err =
- endpoint_lines(right_source, { worktree_lines = opts.worktree_lines })
- if not new_content then
- return nil, right_err
- end
- local split_hunks = split_hunks_for(opts.diff_lines, spec)
- local difft = difft_alignment(spec, old_content, new_content)
- if difft and not difft.changed then
- log.notify('difftastic: no structural changes (formatting only)', vim.log.levels.INFO)
- end
- local alignment, left_info, right_info =
- build_pane_infos(old_content, new_content, split_hunks, difft)
-
- local reuse = opts.reuse_wins
- local invoking_win = vim.api.nvim_get_current_win()
-
- delete_existing_pair_buffers(left_source, right_source)
-
- local reusing = reuse ~= nil
- and vim.api.nvim_win_is_valid(reuse.left)
- and vim.api.nvim_win_is_valid(reuse.right)
-
- ---@type integer[]
- local stale_buffers = {}
- local left_win, right_win
- if reusing then
- left_win, right_win = reuse.left, reuse.right
- for _, win in ipairs({ left_win, right_win }) do
- local buf = vim.api.nvim_win_get_buf(win)
- stale_buffers[#stale_buffers + 1] = buf
- closing_buffers[buf] = true
- end
- else
- if vim.api.nvim_win_is_valid(invoking_win) then
- vim.api.nvim_set_current_win(invoking_win)
- end
- left_win = vim.api.nvim_get_current_win()
- vim.cmd('rightbelow vsplit')
- right_win = vim.api.nvim_get_current_win()
- end
-
- local left_buf = create_buffer(left_source, alignment.left_lines, left_info, split_hunks)
- local right_buf = create_buffer(right_source, alignment.right_lines, right_info, split_hunks)
-
- vim.api.nvim_win_set_buf(left_win, left_buf)
- vim.api.nvim_win_set_buf(right_win, right_buf)
- set_peers(left_buf, right_buf)
- track_split_window(left_win, left_buf)
- track_split_window(right_win, right_buf)
-
- set_pair_window_options(left_win)
- set_pair_window_options(right_win)
- vim.api.nvim_set_current_win(right_win)
- lists.set_for_split_pair({
- title = opts.title or ('diff: ' .. diffspec.label(spec)),
- left_buf = left_buf,
- right_buf = right_buf,
- left_win = left_win,
- right_win = right_win,
- hunks = split_hunks,
- anchors = alignment.anchors,
- quickfix = opts.quickfix,
- })
-
- if opts.hunk_index and alignment.anchors[opts.hunk_index] then
- move_pair_to_row(right_buf, alignment.anchors[opts.hunk_index])
- end
-
- for _, buf in ipairs(stale_buffers) do
- clear_pair_tracking(buf, true)
- if buf ~= left_buf and buf ~= right_buf and vim.api.nvim_buf_is_valid(buf) then
- pcall(vim.api.nvim_buf_delete, buf, { force = true })
- end
- closing_buffers[buf] = nil
- end
-
- log.dbg('opened split diff buffers %d/%d for %s', left_buf, right_buf, diffspec.label(spec))
- return {
- left_buf = left_buf,
- right_buf = right_buf,
- left_win = left_win,
- right_win = right_win,
- },
- nil
-end
-
----@param source table
----@return diffs.SplitEndpointSource
-function normalize_source(source)
- return {
- version = 1,
- kind = 'split_endpoint',
- repo_root = source.repo_root,
- spec = diffspec.new(source.spec),
- side = source.side,
- path = source.path,
- filetype = source.filetype,
- quickfix = source.quickfix,
- worktree_buf = source.worktree_buf,
- }
-end
-
----@param bufnr integer
----@param source table
----@param opts? { quickfix?: boolean }
----@return boolean, string?
-function M.read_buffer(bufnr, source, opts)
- opts = opts or {}
- source = normalize_source(source)
-
- local peer = split_peer(bufnr)
- if not peer or peer == bufnr then
- detach_split_endpoint(bufnr)
- return false, 'cannot reload split endpoint without a valid peer'
- end
-
- local peer_source = peer and buffer_source(peer) or nil
- if peer and not peer_source then
- detach_split_endpoint(bufnr)
- return false, 'cannot reload split peer without source metadata'
- end
-
- local current_lines, current_err = endpoint_lines(source)
- if not current_lines then
- return false, current_err
- end
-
- local peer_lines
- if peer_source then
- local peer_err
- peer_lines, peer_err = endpoint_lines(peer_source)
- if not peer_lines then
- return false, peer_err
- end
- end
-
- local split_hunks
- if peer_source then
- local diff_lines, diff_err = render.file(source.spec, source.repo_root, {
- worktree_buf = source.worktree_buf,
- })
- if not diff_lines then
- return false, diff_err
- end
- split_hunks = split_hunks_for(diff_lines, source.spec)
- end
-
- if not (peer_source and peer_lines) then
- return false, 'cannot reload split pair without peer content'
- end
-
- local left_buf = source.side == 'left' and bufnr or peer
- local right_buf = source.side == 'right' and bufnr or peer
- local left_source = source.side == 'left' and source or peer_source
- local right_source = source.side == 'right' and source or peer_source
- local old_content = source.side == 'left' and current_lines or peer_lines
- local new_content = source.side == 'right' and current_lines or peer_lines
-
- local difft = difft_alignment(source.spec, old_content, new_content)
- local alignment, left_info, right_info =
- build_pane_infos(old_content, new_content, split_hunks or {}, difft)
- apply_buffer_lines(left_buf, left_source, alignment.left_lines, left_info, split_hunks or {})
- apply_buffer_lines(right_buf, right_source, alignment.right_lines, right_info, split_hunks or {})
- set_peers(left_buf, right_buf)
- refresh_visible_pair_options(left_buf)
- lists.set_for_split_pair({
- title = 'diff: ' .. diffspec.label(source.spec),
- left_buf = left_buf,
- right_buf = right_buf,
- hunks = split_hunks,
- anchors = alignment.anchors,
- quickfix = opts.quickfix ~= false and source.quickfix,
- })
- return true, nil
-end
-
----@param bufnr? integer
----@param keep_win integer
----@return table?
-function M.close_pair_into_window(bufnr, keep_win)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
- if type(keep_win) ~= 'number' then
- return nil
- end
- return close_pair_into_window(bufnr, keep_win)
-end
-
----@param buffers table
-function M.delete_pair_buffers(buffers)
- delete_pair_buffers(buffers)
-end
-
----@param bufnr? integer
----@return boolean
-function M.close_pair(bufnr)
- bufnr = bufnr or vim.api.nvim_get_current_buf()
-
- local buffers = pair_buffers_for(bufnr)
- if vim.tbl_isempty(buffers) then
- return false
- end
-
- for buf in pairs(buffers) do
- if closing_buffers[buf] then
- return false
- end
- end
-
- local pair_wins, non_pair_wins = tab_windows_for_buffers(buffers)
-
- for _, win in ipairs(pair_wins) do
- split_windows[win] = nil
- end
- for buf in pairs(buffers) do
- closing_buffers[buf] = true
- end
-
- local keep_win
- if #non_pair_wins == 0 then
- local current_win = vim.api.nvim_get_current_win()
- for _, win in ipairs(pair_wins) do
- if win == current_win then
- keep_win = win
- break
- end
- end
- keep_win = keep_win or pair_wins[1]
- if keep_win and vim.api.nvim_win_is_valid(keep_win) then
- vim.api.nvim_set_current_win(keep_win)
- clear_pair_window_options(keep_win)
- vim.cmd.enew()
- end
- end
-
- for _, win in ipairs(pair_wins) do
- if win ~= keep_win then
- clear_pair_window_options(win)
- end
- if win ~= keep_win and vim.api.nvim_win_is_valid(win) then
- pcall(vim.api.nvim_win_close, win, true)
- end
- end
-
- delete_pair_buffers(buffers)
-
- local focus_win = non_pair_wins[1] or keep_win
- if focus_win and vim.api.nvim_win_is_valid(focus_win) then
- vim.api.nvim_set_current_win(focus_win)
- end
-
- return true
-end
-
----@param win integer
----@return boolean
-function M.release_pair_window_options(win)
- if not vim.api.nvim_win_is_valid(win) or not pair_window_options[win] then
- return false
- end
-
- clear_pair_window_options(win)
- return true
-end
-
----@param rows diffs.SplitRow[]
----@param cursor_row integer
----@param lnum_field "old_lnum"|"new_lnum"
----@return diffs.SplitRow?
-local function nearest_non_filler_row(rows, cursor_row, lnum_field)
- for offset = 0, #rows do
- local above = rows[cursor_row - offset]
- if above and above.kind ~= 'filler' and above[lnum_field] then
- return above
- end
- local below = rows[cursor_row + offset]
- if below and below.kind ~= 'filler' and below[lnum_field] then
- return below
- end
- end
- return nil
-end
-
----@param bufnr integer
----@param side "left"|"right"
----@return integer?
-local function pane_buffer_for_side(bufnr, side)
- local source = buffer_source(bufnr)
- if source and source.side == side then
- return bufnr
- end
-
- local peer = split_peer(bufnr)
- local peer_source = peer and buffer_source(peer) or nil
- if peer_source and peer_source.side == side then
- return peer
- end
- return nil
-end
-
----@param bufnr integer
----@param row? integer
----@return integer?
-local function resolve_pane_row(bufnr, row)
- if row then
- return row
- end
-
- local win = current_or_first_window_for_buffer(bufnr)
- if not win then
- return nil
- end
- return vim.api.nvim_win_get_cursor(win)[1]
-end
-
----@param bufnr integer
----@param row? integer
----@return diffs.SourcePosition?
-local function source_position(bufnr, row)
- local source = buffer_source(bufnr)
- local info = pane_info[bufnr]
- local pane_row = resolve_pane_row(bufnr, row)
- if not source or not info or not info.rows or not pane_row then
- return nil
- end
-
- local lnum_field = side_config[source.side].lnum
- local matched = nearest_non_filler_row(info.rows, pane_row, lnum_field)
- if not matched then
- return nil
- end
- return { side = source.side, lnum = matched[lnum_field] }
-end
-
----@param bufnr integer
----@param side "left"|"right"
----@param row integer
----@return diffs.SourcePosition?
-local function position_at_row(bufnr, side, row)
- local pane = pane_buffer_for_side(bufnr, side)
- local info = pane and pane_info[pane]
- local entry = info and info.rows and info.rows[row]
- if not entry or entry.kind == 'filler' then
- return nil
- end
-
- local lnum = entry[side_config[side].lnum]
- if not lnum then
- return nil
- end
- return { side = side, lnum = lnum }
-end
-
----@param bufnr integer
----@param row? integer
----@return diffs.SourcePosition?
-function M.displayed_position(bufnr, row)
- local source = buffer_source(bufnr)
- local pane_row = resolve_pane_row(bufnr, row)
- if not source or not pane_row then
- return nil
- end
-
- local peer_side = source.side == 'left' and 'right' or 'left'
- return position_at_row(bufnr, source.side, pane_row)
- or position_at_row(bufnr, peer_side, pane_row)
-end
-
----@param bufnr integer
----@param position diffs.SourcePosition?
----@return boolean
-function M.move_pair_to_source(bufnr, position)
- if type(position) ~= 'table' or not side_config[position.side] then
- return false
- end
-
- local pane = pane_buffer_for_side(bufnr, position.side)
- local info = pane and pane_info[pane]
- if not info or not info.rows then
- return false
- end
-
- local lnum_field = side_config[position.side].lnum
- for row, entry in ipairs(info.rows) do
- if entry.kind ~= 'filler' and entry[lnum_field] == position.lnum then
- move_pair_to_row(bufnr, row)
- return true
- end
- end
- return false
-end
-
----@param bufnr integer
----@return boolean
-function M.open_source(bufnr)
- local source = buffer_source(bufnr)
- if not source then
- notify('missing split diff source metadata', vim.log.levels.WARN)
- return false
- end
-
- local endpoint = side_endpoint(source.spec, source.side)
- if endpoint.kind ~= diffspec.endpoint_kind.worktree then
- if endpoint.kind == diffspec.endpoint_kind.index then
- notify('cannot open index-backed split endpoint as a worktree file', vim.log.levels.WARN)
- else
- notify(
- 'cannot open read-only tree-backed split endpoint as a worktree file',
- vim.log.levels.WARN
- )
- end
- return false
- end
-
- local source_win = current_or_first_window_for_buffer(bufnr)
- if not source_win then
- notify('cannot open hidden split endpoint as a source file', vim.log.levels.WARN)
- return false
- end
-
- local filepath = resolve_worktree_path(source.repo_root, source.path)
- local cursor_row = vim.api.nvim_win_get_cursor(source_win)[1]
- local position = source_position(bufnr, cursor_row)
- local target_lnum = (position and position.lnum) or cursor_row
- local existing_win = find_window_for_file(filepath)
- if existing_win then
- vim.api.nvim_set_current_win(existing_win)
- else
- local buffers = close_pair_into_window(bufnr, source_win)
- if not buffers then
- notify('cannot close split pair before opening source file', vim.log.levels.WARN)
- return false
- end
- vim.cmd.edit(vim.fn.fnameescape(filepath))
- delete_pair_buffers(buffers)
- end
- local lnum = math.max(1, math.min(target_lnum, vim.api.nvim_buf_line_count(0)))
- vim.api.nvim_win_set_cursor(0, { lnum, 0 })
- return true
-end
-
----@param bufnr integer
-function M.goto_next(bufnr)
- goto_anchor(bufnr, 1)
-end
-
----@param bufnr integer
-function M.goto_prev(bufnr)
- goto_anchor(bufnr, -1)
-end
-
----@param bufnr integer
----@param hunk_index integer?
----@return boolean
-function M.move_pair_to_hunk_index(bufnr, hunk_index)
- if type(hunk_index) ~= 'number' then
- return false
- end
-
- local info = pane_info[bufnr]
- local row = info and info.anchors and info.anchors[hunk_index]
- if not row then
- return false
- end
-
- move_pair_to_row(bufnr, row)
- return true
-end
-
----@param bufnr integer
-function M.sync_cursor_to_hunk(bufnr)
- local win = current_or_first_window_for_buffer(bufnr)
- if not win then
- return
- end
- move_pair_to_row(bufnr, vim.api.nvim_win_get_cursor(win)[1])
-end
-
-M._test = {
- buffer_name = buffer_name,
- endpoint_lines = endpoint_lines,
- split_hunks_for = split_hunks_for,
-}
-
-return M
diff --git a/lua/diffs/split_align.lua b/lua/diffs/split_align.lua
deleted file mode 100644
index 2dc1ff9b..00000000
--- a/lua/diffs/split_align.lua
+++ /dev/null
@@ -1,118 +0,0 @@
-local M = {}
-
-local FILLER = ''
-
----@class diffs.SplitRow
----@field kind "context"|"delete"|"add"|"filler"
----@field old_lnum integer?
----@field new_lnum integer?
----@field hunk_index integer?
-
----@class diffs.SplitAlignment
----@field left_lines string[]
----@field right_lines string[]
----@field left_rows diffs.SplitRow[]
----@field right_rows diffs.SplitRow[]
----@field anchors integer[] buffer row (1-indexed) of each hunk's first changed line
-
----@param old_lines string[]
----@param new_lines string[]
----@param hunks diffs.DiffHunk[]
----@return diffs.SplitAlignment
-function M.align(old_lines, new_lines, hunks)
- local left_lines, right_lines = {}, {}
- local left_rows, right_rows = {}, {}
- local anchors = {}
-
- local function push(lt, lr, rt, rr)
- left_lines[#left_lines + 1] = lt
- left_rows[#left_rows + 1] = lr
- right_lines[#right_lines + 1] = rt
- right_rows[#right_rows + 1] = rr
- end
-
- local function push_context(old_lnum, new_lnum, hunk_index)
- push(
- old_lines[old_lnum] or '',
- {
- kind = 'context',
- old_lnum = old_lnum,
- new_lnum = new_lnum,
- hunk_index = hunk_index,
- },
- new_lines[new_lnum] or '',
- {
- kind = 'context',
- old_lnum = old_lnum,
- new_lnum = new_lnum,
- hunk_index = hunk_index,
- }
- )
- end
-
- local function flush_change(dels, adds, hunk_index)
- if hunk_index and not anchors[hunk_index] and (#dels > 0 or #adds > 0) then
- anchors[hunk_index] = #left_lines + 1
- end
- for i = 1, math.max(#dels, #adds) do
- local d, a = dels[i], adds[i]
- push(
- d and (old_lines[d] or '') or FILLER,
- d and { kind = 'delete', old_lnum = d, hunk_index = hunk_index }
- or { kind = 'filler', hunk_index = hunk_index },
- a and (new_lines[a] or '') or FILLER,
- a and { kind = 'add', new_lnum = a, hunk_index = hunk_index }
- or { kind = 'filler', hunk_index = hunk_index }
- )
- end
- end
-
- local old_cursor, new_cursor = 1, 1
- for _, hunk in ipairs(hunks) do
- while old_cursor < hunk.old_range.start do
- push_context(old_cursor, new_cursor)
- old_cursor = old_cursor + 1
- new_cursor = new_cursor + 1
- end
-
- local dels, adds = {}, {}
- local function flush()
- if #dels > 0 or #adds > 0 then
- flush_change(dels, adds, hunk.index)
- dels, adds = {}, {}
- end
- end
-
- for _, line in ipairs(hunk.lines) do
- if line.kind == 'context' then
- flush()
- push_context(line.old_lnum, line.new_lnum, hunk.index)
- old_cursor = (line.old_lnum or old_cursor) + 1
- new_cursor = (line.new_lnum or new_cursor) + 1
- elseif line.kind == 'delete' then
- dels[#dels + 1] = line.old_lnum
- old_cursor = (line.old_lnum or old_cursor) + 1
- elseif line.kind == 'add' then
- adds[#adds + 1] = line.new_lnum
- new_cursor = (line.new_lnum or new_cursor) + 1
- end
- end
- flush()
- end
-
- while old_cursor <= #old_lines do
- push_context(old_cursor, new_cursor)
- old_cursor = old_cursor + 1
- new_cursor = new_cursor + 1
- end
-
- return {
- left_lines = left_lines,
- right_lines = right_lines,
- left_rows = left_rows,
- right_rows = right_rows,
- anchors = anchors,
- }
-end
-
-return M
diff --git a/plugin/diffs.lua b/plugin/diffs.lua
index e9d907e5..8f586d89 100644
--- a/plugin/diffs.lua
+++ b/plugin/diffs.lua
@@ -14,30 +14,6 @@ local function get_runtime()
return runtime
end
-local function get_commands()
- return require('diffs.commands')
-end
-
-local function has_conflict_marker(bufnr)
- if vim.api.nvim_get_option_value('buftype', { buf = bufnr }) ~= '' then
- return false
- end
- return vim.api.nvim_buf_call(bufnr, function()
- return vim.fn.search('^<<<<<<<', 'cnw') > 0
- end)
-end
-
-vim.api.nvim_create_user_command('Diff', function(opts)
- get_commands().diff_command(opts.args ~= '' and opts.args or nil, opts.smods.vertical)
-end, {
- nargs = '*',
- bar = true,
- complete = function(...)
- return get_commands().complete_diff_command(...)
- end,
- desc = 'Show a current-file diff, a repository review with :Diff review, or two files with :Diff files',
-})
-
local integration_config = user_config.integrations or {}
if integrations.is_enabled(integration_config.gitsigns) then
@@ -65,38 +41,6 @@ vim.api.nvim_create_autocmd('FileType', {
callback = function(args)
local runtime = get_runtime()
runtime.attach(args.buf)
-
- if args.match == 'fugitive' then
- local fugitive_config = runtime.get_fugitive_config()
- if fugitive_config == true then
- require('diffs.fugitive').setup_keymaps(args.buf)
- end
- end
- end,
-})
-
-vim.api.nvim_create_autocmd('BufReadCmd', {
- pattern = 'diffs://*',
- callback = function(args)
- vim.cmd(
- ('lockmarks lua require("diffs.commands").read_buffer(%d, { quickfix = %s })'):format(
- args.buf,
- tostring(vim.b[args.buf].diffs_source ~= nil)
- )
- )
- end,
-})
-
-vim.api.nvim_create_autocmd('BufReadPost', {
- callback = function(args)
- if not user_config.conflict or not has_conflict_marker(args.buf) then
- return
- end
- local runtime = get_runtime()
- local conflict_config = runtime.get_conflict_config()
- if conflict_config then
- require('diffs.conflict').attach(args.buf)
- end
end,
})
@@ -123,85 +67,7 @@ vim.api.nvim_create_autocmd('VimEnter', {
vim.api.nvim_create_autocmd('OptionSet', {
pattern = 'diffopt',
callback = function()
- require('diffs.commands').on_diffopt_changed()
+ get_runtime().invalidate_attached()
+ vim.cmd('redraw!')
end,
})
-
-vim.keymap.set('n', '(diffs-diff)', function()
- get_commands().diff(nil, false)
-end, { desc = 'Unified diff (horizontal)' })
-vim.keymap.set('n', '(diffs-diff-vertical)', function()
- get_commands().diff(nil, true)
-end, { desc = 'Unified diff (vertical)' })
-vim.keymap.set('n', '(diffs-diff-open-source)', function()
- require('diffs.hunks').open_source(vim.api.nvim_get_current_buf())
-end, { desc = 'Open source file from generated diff' })
-vim.keymap.set('n', '(diffs-review-open-split)', function()
- get_commands().review_split()
-end, { desc = 'Open review file in split diff' })
-vim.keymap.set('n', '(diffs-review-toggle-layout)', function()
- get_commands().review_toggle_layout()
-end, { desc = 'Toggle review layout' })
-vim.keymap.set('n', '(diffs-review-next-file)', function()
- for _ = 1, vim.v.count1 do
- get_commands().review_next_file()
- end
-end, { desc = 'Next file in review split' })
-vim.keymap.set('n', '(diffs-review-prev-file)', function()
- for _ = 1, vim.v.count1 do
- get_commands().review_prev_file()
- end
-end, { desc = 'Previous file in review split' })
-vim.keymap.set('n', '(diffs-review-select-file)', function()
- get_commands().select_review_file()
-end, { desc = 'Pick a file in the review split' })
-vim.keymap.set('n', '(diffs-refresh)', function()
- get_commands().refresh()
-end, { desc = 'Re-render the current diff buffer' })
-
-local function conflict_action(fn)
- local bufnr = vim.api.nvim_get_current_buf()
- local runtime = get_runtime()
- local conflict_config = runtime.get_conflict_config()
- if conflict_config then
- fn(bufnr)
- end
-end
-
-vim.keymap.set('n', '(diffs-conflict-ours)', function()
- conflict_action(require('diffs.conflict').resolve_ours)
-end, { desc = 'Accept current (ours) change' })
-vim.keymap.set('n', '(diffs-conflict-theirs)', function()
- conflict_action(require('diffs.conflict').resolve_theirs)
-end, { desc = 'Accept incoming (theirs) change' })
-vim.keymap.set('n', '(diffs-conflict-both)', function()
- conflict_action(require('diffs.conflict').resolve_both)
-end, { desc = 'Accept both changes' })
-vim.keymap.set('n', '(diffs-conflict-none)', function()
- conflict_action(require('diffs.conflict').resolve_none)
-end, { desc = 'Reject both changes' })
-vim.keymap.set('n', '(diffs-conflict-next)', function()
- conflict_action(require('diffs.conflict').goto_next)
-end, { desc = 'Jump to next conflict' })
-vim.keymap.set('n', '(diffs-conflict-prev)', function()
- conflict_action(require('diffs.conflict').goto_prev)
-end, { desc = 'Jump to previous conflict' })
-
-vim.keymap.set('n', '(diffs-merge-ours)', function()
- conflict_action(require('diffs.merge').resolve_ours)
-end, { desc = 'Accept ours in merge diff' })
-vim.keymap.set('n', '(diffs-merge-theirs)', function()
- conflict_action(require('diffs.merge').resolve_theirs)
-end, { desc = 'Accept theirs in merge diff' })
-vim.keymap.set('n', '(diffs-merge-both)', function()
- conflict_action(require('diffs.merge').resolve_both)
-end, { desc = 'Accept both in merge diff' })
-vim.keymap.set('n', '(diffs-merge-none)', function()
- conflict_action(require('diffs.merge').resolve_none)
-end, { desc = 'Reject both in merge diff' })
-vim.keymap.set('n', '(diffs-merge-next)', function()
- conflict_action(require('diffs.merge').goto_next)
-end, { desc = 'Jump to next conflict hunk' })
-vim.keymap.set('n', '(diffs-merge-prev)', function()
- conflict_action(require('diffs.merge').goto_prev)
-end, { desc = 'Jump to previous conflict hunk' })
diff --git a/spec/actions_spec.lua b/spec/actions_spec.lua
deleted file mode 100644
index 9a5b6eb8..00000000
--- a/spec/actions_spec.lua
+++ /dev/null
@@ -1,735 +0,0 @@
-require('spec.helpers')
-
-local actions = require('diffs.actions')
-local commands = require('diffs.commands')
-local diffspec = require('diffs.spec')
-local generated = require('diffs.generated')
-local git = require('diffs.git')
-local hunk_model = require('diffs.hunks')
-local render = require('diffs.render')
-
-local test_buffers = {}
-local test_repos = {}
-local saved_notify
-local saved_git_apply_patch
-local buffer_counter = 0
-
-local function git_cmd(repo_root, args)
- local cmd = { 'git', '-C', repo_root }
- for _, arg in ipairs(args) do
- cmd[#cmd + 1] = arg
- end
- local output = vim.fn.systemlist(cmd)
- assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n'))
- return output
-end
-
-local function git_text(repo_root, args)
- return table.concat(git_cmd(repo_root, args), '\n')
-end
-
-local function write_repo_file(repo_root, lines)
- vim.fn.writefile(lines, repo_root .. '/file.txt')
-end
-
-local function file_lines(overrides)
- local lines = {}
- for i = 1, 30 do
- lines[i] = 'line ' .. i
- end
- for lnum, line in pairs(overrides or {}) do
- lines[lnum] = line
- end
- return lines
-end
-
-local function file_lines_with_insertions(after, inserted)
- local lines = file_lines()
- for offset, line in ipairs(inserted) do
- table.insert(lines, after + offset, line)
- end
- return lines
-end
-
-local function file_lines_without(start, count)
- local lines = file_lines()
- for _ = 1, count do
- table.remove(lines, start)
- end
- return lines
-end
-
-local function create_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- write_repo_file(repo_root, file_lines())
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'initial' })
-
- return repo_root
-end
-
-local function create_diff_buffer(repo_root, diff_spec)
- local diff_lines = assert(render.file(diff_spec, repo_root))
- buffer_counter = buffer_counter + 1
- local bufnr = vim.api.nvim_create_buf(false, true)
- test_buffers[#test_buffers + 1] = bufnr
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines)
- vim.api.nvim_buf_set_name(bufnr, 'diffs://actions-' .. buffer_counter .. ':file.txt')
- generated.set_source(bufnr, generated.file_source(repo_root, diff_spec))
- vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', hunk_model.parse(diff_lines, diff_spec))
- vim.api.nvim_set_current_buf(bufnr)
- commands.setup_diff_buf(bufnr)
- return bufnr
-end
-
-local function keymap_callback(bufnr, lhs, mode)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode or 'n')) do
- if keymap.lhs == lhs then
- return keymap.callback
- end
- end
- return nil
-end
-
-local function find_hunk_line(bufnr, kind, text)
- for _, hunk in ipairs(vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')) do
- for _, line in ipairs(hunk.lines) do
- if line.kind == kind and line.text == text then
- return line, hunk
- end
- end
- end
- error('could not find hunk line ' .. kind .. ' ' .. text)
-end
-
-local function find_hunks(bufnr)
- return vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
-end
-
-local function set_visual_range(bufnr, range_start, range_finish)
- vim.api.nvim_buf_set_mark(bufnr, '<', range_start, 0, {})
- vim.api.nvim_buf_set_mark(bufnr, '>', range_finish, 0, {})
-end
-
-local function move_to_hunk(bufnr, index)
- local hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- vim.api.nvim_win_set_cursor(0, { hunks[index].buffer_range.start, 0 })
-end
-
-local function buffer_text(bufnr)
- return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n')
-end
-
-local function capture_notifications()
- local notifications = {}
- saved_notify = vim.notify
- vim.notify = function(message, level)
- notifications[#notifications + 1] = { message = message, level = level }
- end
- return notifications
-end
-
-local function cleanup()
- if saved_notify then
- vim.notify = saved_notify
- saved_notify = nil
- end
- if saved_git_apply_patch then
- git.apply_patch = saved_git_apply_patch
- saved_git_apply_patch = nil
- end
- for _, bufnr in ipairs(test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- test_buffers = {}
- for _, repo_root in ipairs(test_repos) do
- vim.fn.delete(repo_root, 'rf')
- end
- test_repos = {}
-end
-
-describe('diffs.actions', function()
- after_each(cleanup)
-
- it('builds a one-hunk patch from stored file headers and hunk lines', function()
- local diff_lines = {
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1 +1 @@',
- '-one',
- '+two',
- '@@ -10 +10 @@',
- '-ten',
- '+eleven',
- }
- local hunks = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))
-
- local patch = assert(actions.patch_for_hunk(hunks[2]))
-
- assert.are.equal(
- table.concat({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -10 +10 @@',
- '-ten',
- '+eleven',
- '',
- }, '\n'),
- patch
- )
- end)
-
- it('builds a selected-add range patch for the side receiving the patch', function()
- local diff_lines = {
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1 +1,3 @@',
- ' line 1',
- '+insert alpha',
- '+insert beta',
- }
- local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1]
-
- local patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'left' }))
- local reverse_patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'right' }))
-
- assert.are.equal(
- table.concat({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1 +1,3 @@',
- ' line 1',
- '+insert beta',
- '',
- }, '\n'),
- patch
- )
- assert.are.equal(
- table.concat({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1 +1,3 @@',
- ' line 1',
- ' insert alpha',
- '+insert beta',
- '',
- }, '\n'),
- reverse_patch
- )
- end)
-
- it('builds a selected-delete range patch for the side receiving the patch', function()
- local diff_lines = {
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1,3 +1 @@',
- ' line 1',
- '-delete alpha',
- '-delete beta',
- }
- local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1]
-
- local patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'left' }))
- local reverse_patch = assert(actions.patch_for_range(hunk, 7, 7, { target = 'right' }))
-
- assert.are.equal(
- table.concat({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1,3 +1 @@',
- ' line 1',
- ' delete alpha',
- '-delete beta',
- '',
- }, '\n'),
- patch
- )
- assert.are.equal(
- table.concat({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1,3 +1 @@',
- ' line 1',
- '-delete beta',
- '',
- }, '\n'),
- reverse_patch
- )
- end)
-
- it('rejects partial replacement-group ranges before building a patch', function()
- local diff_lines = {
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -1,2 +1,2 @@',
- ' line 1',
- '-old',
- '+new',
- }
- local hunk = hunk_model.parse(diff_lines, diffspec.index_to_worktree('file.txt'))[1]
-
- local patch, err = actions.patch_for_range(hunk, 7, 7)
-
- assert.is_nil(patch)
- assert.are.equal('select the complete replacement group before applying it', err)
- end)
-
- it('stages only the current unstaged hunk and refreshes the buffer', function()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines({
- [2] = 'line 2 changed',
- [22] = 'line 22 changed',
- })
- )
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_not_nil(keymap_callback(bufnr, 'dp'))
- keymap_callback(bufnr, 'dp')()
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.is_true(cached:find('+line 2 changed', 1, true) ~= nil)
- assert.is_nil(cached:find('+line 22 changed', 1, true))
- assert.is_nil(worktree:find('+line 2 changed', 1, true))
- assert.is_true(worktree:find('+line 22 changed', 1, true) ~= nil)
- assert.is_nil(buffer_text(bufnr):find('+line 2 changed', 1, true))
- assert.is_true(buffer_text(bufnr):find('+line 22 changed', 1, true) ~= nil)
- end)
-
- it('stages an untracked file hunk as a new file', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'new file line' }, repo_root .. '/new.txt')
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('new.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_true(actions.put_hunk(bufnr))
-
- local cached =
- git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'new.txt' })
- local worktree = git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'new.txt' })
-
- assert.is_true(cached:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(cached:find('--- /dev/null', 1, true) ~= nil)
- assert.is_true(cached:find('+new file line', 1, true) ~= nil)
- assert.are.equal('', worktree)
- end)
-
- it('stages only selected added lines from a visual range', function()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines_with_insertions(2, {
- 'insert alpha',
- 'insert beta',
- })
- )
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local selected = find_hunk_line(bufnr, 'add', '+insert beta')
-
- assert.is_true(actions.put_range(bufnr, selected.lnum, selected.lnum))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.is_true(cached:find('+insert beta', 1, true) ~= nil)
- assert.is_nil(cached:find('+insert alpha', 1, true))
- assert.is_true(worktree:find('+insert alpha', 1, true) ~= nil)
- assert.is_nil(worktree:find('+insert beta', 1, true))
- end)
-
- it('stages a visual selection through the buffer-local dp map and refreshes', function()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines_with_insertions(2, {
- 'insert alpha',
- 'insert beta',
- })
- )
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local selected = find_hunk_line(bufnr, 'add', '+insert beta')
- local callback = assert(keymap_callback(bufnr, 'dp', 'x'))
-
- set_visual_range(bufnr, selected.lnum, selected.lnum)
- callback()
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
-
- assert.is_true(cached:find('+insert beta', 1, true) ~= nil)
- assert.is_nil(cached:find('+insert alpha', 1, true))
- assert.is_nil(buffer_text(bufnr):find('+insert beta', 1, true))
- assert.is_true(buffer_text(bufnr):find('+insert alpha', 1, true) ~= nil)
- end)
-
- it('stages only selected deleted lines from a visual range', function()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines_without(3, 2))
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local selected = find_hunk_line(bufnr, 'delete', '-line 4')
-
- assert.is_true(actions.put_range(bufnr, selected.lnum, selected.lnum))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.is_true(cached:find('-line 4', 1, true) ~= nil)
- assert.is_nil(cached:find('-line 3', 1, true))
- assert.is_true(worktree:find('-line 3', 1, true) ~= nil)
- assert.is_nil(worktree:find('-line 4', 1, true))
- end)
-
- it('unstages only the current staged hunk and refreshes the buffer', function()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- git_cmd(repo_root, { 'add', 'file.txt' })
- write_repo_file(
- repo_root,
- file_lines({
- [2] = 'line 2 changed',
- [22] = 'line 22 changed',
- })
- )
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_not_nil(keymap_callback(bufnr, 'do'))
- keymap_callback(bufnr, 'do')()
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.are.equal('', cached)
- assert.is_true(worktree:find('+line 2 changed', 1, true) ~= nil)
- assert.is_true(worktree:find('+line 22 changed', 1, true) ~= nil)
- assert.are.equal(0, #vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks'))
- end)
-
- it('unstages a staged added-file hunk back to an untracked file', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'new file line' }, repo_root .. '/new.txt')
- git_cmd(repo_root, { 'add', 'new.txt' })
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('new.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_true(actions.obtain_hunk(bufnr))
-
- local cached =
- git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'new.txt' })
- vim.fn.systemlist({ 'git', '-C', repo_root, 'ls-files', '--error-unmatch', 'new.txt' })
-
- assert.are.equal('', cached)
- assert.is_not.equal(0, vim.v.shell_error)
- end)
-
- it('stages an unstaged deletion hunk', function()
- local repo_root = create_repo()
- vim.fn.delete(repo_root .. '/file.txt')
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_true(actions.put_hunk(bufnr))
-
- local cached =
- git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'file.txt' })
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'file.txt' })
-
- assert.is_true(cached:find('deleted file mode 100644', 1, true) ~= nil)
- assert.is_true(cached:find('+++ /dev/null', 1, true) ~= nil)
- assert.is_true(cached:find('-line 1', 1, true) ~= nil)
- assert.are.equal('', worktree)
- end)
-
- it('unstages a staged deletion hunk back to an unstaged deletion', function()
- local repo_root = create_repo()
- vim.fn.delete(repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', '-u', 'file.txt' })
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_true(actions.obtain_hunk(bufnr))
-
- local cached =
- git_text(repo_root, { 'diff', '--cached', '--no-ext-diff', '--no-color', '--', 'file.txt' })
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '--', 'file.txt' })
-
- assert.are.equal('', cached)
- assert.is_true(worktree:find('deleted file mode 100644', 1, true) ~= nil)
- assert.is_true(worktree:find('+++ /dev/null', 1, true) ~= nil)
- assert.is_true(worktree:find('-line 1', 1, true) ~= nil)
- end)
-
- it('unstages only selected added lines from a visual range', function()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines_with_insertions(2, {
- 'insert alpha',
- 'insert beta',
- })
- )
- git_cmd(repo_root, { 'add', 'file.txt' })
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- local selected = find_hunk_line(bufnr, 'add', '+insert beta')
-
- assert.is_true(actions.obtain_range(bufnr, selected.lnum, selected.lnum))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.is_true(cached:find('+insert alpha', 1, true) ~= nil)
- assert.is_nil(cached:find('+insert beta', 1, true))
- assert.is_true(worktree:find('+insert beta', 1, true) ~= nil)
- assert.is_nil(worktree:find('+insert alpha', 1, true))
- end)
-
- it('unstages a visual selection through the buffer-local do map and refreshes', function()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines_with_insertions(2, {
- 'insert alpha',
- 'insert beta',
- })
- )
- git_cmd(repo_root, { 'add', 'file.txt' })
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- local selected = find_hunk_line(bufnr, 'add', '+insert beta')
- local callback = assert(keymap_callback(bufnr, 'do', 'x'))
-
- set_visual_range(bufnr, selected.lnum, selected.lnum)
- callback()
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
-
- assert.is_true(cached:find('+insert alpha', 1, true) ~= nil)
- assert.is_nil(cached:find('+insert beta', 1, true))
- assert.is_nil(buffer_text(bufnr):find('+insert beta', 1, true))
- assert.is_true(buffer_text(bufnr):find('+insert alpha', 1, true) ~= nil)
- end)
-
- it('unstages only selected deleted lines from a visual range', function()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines_without(3, 2))
- git_cmd(repo_root, { 'add', 'file.txt' })
-
- local bufnr = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- local selected = find_hunk_line(bufnr, 'delete', '-line 4')
-
- assert.is_true(actions.obtain_range(bufnr, selected.lnum, selected.lnum))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- local worktree =
- git_text(repo_root, { 'diff', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' })
-
- assert.is_true(cached:find('-line 3', 1, true) ~= nil)
- assert.is_nil(cached:find('-line 4', 1, true))
- assert.is_true(worktree:find('-line 4', 1, true) ~= nil)
- assert.is_nil(worktree:find('-line 3', 1, true))
- end)
-
- it('rejects visual ranges that cross diff hunks without touching the index', function()
- local notifications = capture_notifications()
- local repo_root = create_repo()
- write_repo_file(
- repo_root,
- file_lines({
- [2] = 'line 2 changed',
- [22] = 'line 22 changed',
- })
- )
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local hunks = find_hunks(bufnr)
-
- assert.is_false(
- actions.put_range(bufnr, hunks[1].buffer_range.finish, hunks[2].buffer_range.start)
- )
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- assert.are.equal('', cached)
- assert.is_true(
- notifications[#notifications].message:find(
- 'visual selection must stay within one diff hunk',
- 1,
- true
- ) ~= nil
- )
- end)
-
- it('rejects partial replacement visual ranges without touching the index', function()
- local notifications = capture_notifications()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
-
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local selected = find_hunk_line(bufnr, 'add', '+line 2 changed')
-
- assert.is_false(actions.put_range(bufnr, selected.lnum, selected.lnum))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- assert.are.equal('', cached)
- assert.is_true(
- notifications[#notifications].message:find('complete replacement group', 1, true) ~= nil
- )
- end)
-
- it('leaves the index untouched when the patch is stale', function()
- local notifications = capture_notifications()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
-
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 other' }))
- git_cmd(repo_root, { 'add', 'file.txt' })
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- move_to_hunk(bufnr, 1)
- local index_tree = git_text(repo_root, { 'write-tree' })
-
- assert.is_false(actions.put_hunk(bufnr))
- assert.are.equal(index_tree, git_text(repo_root, { 'write-tree' }))
-
- local cached = git_text(
- repo_root,
- { 'diff', '--cached', '--no-ext-diff', '--no-color', '-U0', '--', 'file.txt' }
- )
- assert.is_true(cached:find('+line 2 other', 1, true) ~= nil)
- assert.is_nil(cached:find('+line 2 changed', 1, true))
- assert.is_true(
- notifications[#notifications].message:find('patch does not apply', 1, true) ~= nil
- )
- end)
-
- it('leaves the index untouched when a selected replacement is stale', function()
- local notifications = capture_notifications()
- local repo_root = create_repo()
- local changed = file_lines({ [2] = 'line 2 changed', [5] = 'line 5 changed' })
- write_repo_file(repo_root, changed)
- local bufnr = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- local first = find_hunk_line(bufnr, 'delete', '-line 2')
- local last = find_hunk_line(bufnr, 'add', '+line 2 changed')
-
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 other' }))
- git_cmd(repo_root, { 'add', 'file.txt' })
- write_repo_file(repo_root, changed)
- local index_tree = git_text(repo_root, { 'write-tree' })
-
- assert.is_false(actions.put_range(bufnr, first.lnum, last.lnum))
- assert.are.equal(index_tree, git_text(repo_root, { 'write-tree' }))
- assert.are.same(changed, vim.fn.readfile(repo_root .. '/file.txt'))
- assert.is_true(
- notifications[#notifications].message:find('patch does not apply', 1, true) ~= nil
- )
- end)
-
- it('rejects read-only and unsupported hunk edges without running git apply', function()
- local notifications = capture_notifications()
- local calls = 0
- saved_git_apply_patch = git.apply_patch
- git.apply_patch = function()
- calls = calls + 1
- return true, {}
- end
-
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- local bufnr = create_diff_buffer(repo_root, diffspec.rev_to_worktree('HEAD', 'file.txt'))
- move_to_hunk(bufnr, 1)
-
- assert.is_false(actions.put_hunk(bufnr))
- assert.is_false(actions.obtain_hunk(bufnr))
- assert.are.equal(0, calls)
- assert.is_true(notifications[1].message:find('read-only diff hunk', 1, true) ~= nil)
- assert.is_true(notifications[2].message:find('read-only diff hunk', 1, true) ~= nil)
- end)
-
- it('rejects destructive worktree restore and redundant index put operations', function()
- local notifications = capture_notifications()
- local repo_root = create_repo()
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- local unstaged_buf = create_diff_buffer(repo_root, diffspec.index_to_worktree('file.txt'))
- move_to_hunk(unstaged_buf, 1)
-
- assert.is_false(actions.obtain_hunk(unstaged_buf))
-
- write_repo_file(repo_root, file_lines({ [2] = 'line 2 changed' }))
- git_cmd(repo_root, { 'add', 'file.txt' })
- local staged_buf = create_diff_buffer(repo_root, diffspec.head_to_index('file.txt'))
- move_to_hunk(staged_buf, 1)
-
- assert.is_false(actions.put_hunk(staged_buf))
- assert.is_true(
- notifications[1].message:find('restoring worktree hunks is not supported', 1, true) ~= nil
- )
- assert.is_true(notifications[2].message:find('already in the index', 1, true) ~= nil)
- end)
-end)
diff --git a/spec/api_spec.lua b/spec/api_spec.lua
new file mode 100644
index 00000000..f4427637
--- /dev/null
+++ b/spec/api_spec.lua
@@ -0,0 +1,113 @@
+require('spec.helpers')
+local api = require('diffs')
+local helpers = require('spec.helpers')
+local highlight = require('diffs.highlight')
+local runtime = require('diffs.runtime')
+
+describe('highlighting API', function()
+ local bufnr
+ local ns = vim.api.nvim_create_namespace('diffs')
+
+ after_each(function()
+ helpers.delete_buffer(bufnr)
+ end)
+
+ it('replaces the parser and refreshes supplied hunks without a text edit', function()
+ bufnr = helpers.create_buffer({ 'row -local value = 1', 'row +local value = 2' })
+ vim.api.nvim_set_current_buf(bufnr)
+ api.attach(0)
+ local supplied = {
+ {
+ filename = 'test.lua',
+ lang = 'lua',
+ start_line = 0,
+ lines = { '-local value = 1', '+local value = 2' },
+ prefix_width = 1,
+ quote_width = 0,
+ column_offset = 4,
+ },
+ }
+ local calls = 0
+ api.attach(0, {
+ hunks = function(buffer)
+ assert.are.equal(bufnr, buffer)
+ calls = calls + 1
+ return supplied
+ end,
+ })
+ assert.are.equal(1, calls)
+ local entry = runtime._test.hunk_cache[bufnr]
+ highlight.highlight_hunk(bufnr, ns, entry.hunks[1], api.highlight_options())
+ local marks = helpers.get_extmarks(bufnr, ns)
+ assert.is_true(#marks > 0)
+ for _, mark in ipairs(marks) do
+ assert.is_true(mark[3] >= 4)
+ end
+ assert.is_nil(supplied[1]._hl_line_count)
+
+ supplied = {}
+ api.refresh(0)
+ runtime._test.ensure_cache(bufnr)
+ runtime._test.process_pending_clear(bufnr)
+ assert.are.equal(2, calls)
+ assert.are.same({}, runtime._test.hunk_cache[bufnr].hunks)
+ assert.are.same({}, helpers.get_extmarks(bufnr, ns))
+ end)
+
+ it('keeps no-newline marker rows outside changed text spans', function()
+ bufnr = helpers.create_buffer({
+ 'M test.lua',
+ '@@ -1 +1 @@',
+ '-local value = 1',
+ '\\ No newline at end of file',
+ '+local value = 2',
+ '\\ No newline at end of file',
+ })
+ api.attach(bufnr)
+ local hunk = runtime._test.hunk_cache[bufnr].hunks[1]
+ assert.are.equal(4, #hunk.lines)
+ local spans = api.compute_intra_hunks(hunk.lines)
+ assert.are.equal(1, spans.del_spans[1].line)
+ assert.are.equal(3, spans.add_spans[1].line)
+ local options = api.highlight_options()
+ options.hide_prefix = true
+ highlight.highlight_hunk(bufnr, ns, hunk, options)
+ for _, mark in ipairs(helpers.get_extmarks(bufnr, ns)) do
+ assert.is_not_equal(3, mark[2])
+ assert.is_not_equal(5, mark[2])
+ end
+ end)
+
+ it('rejects deferred syntax from a detached attachment after reattachment', function()
+ bufnr = helpers.create_buffer({
+ 'M test.lua',
+ '@@ -1 +1 @@',
+ '-local value = 1',
+ '+local value = 2',
+ })
+ api.attach(bufnr)
+ local entry = runtime._test.hunk_cache[bufnr]
+ local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
+ local old_job = runtime._test.next_syntax_job(bufnr)
+ vim.api.nvim_buf_set_extmark(bufnr, ns, 2, 1, { end_col = 6, hl_group = 'DiffsDelete' })
+ api.detach(bufnr)
+ assert.is_nil(runtime._test.hunk_cache[bufnr])
+ assert.are.same({}, helpers.get_extmarks(bufnr, ns))
+ api.attach(bufnr)
+ local new_job = runtime._test.next_syntax_job(bufnr)
+ assert.is_false(
+ runtime._test.run_deferred_syntax(bufnr, entry.tick, changedtick, old_job, entry.hunks)
+ )
+ assert.are.same({}, helpers.get_extmarks(bufnr, ns))
+ assert.is_true(
+ runtime._test.run_deferred_syntax(
+ bufnr,
+ entry.tick,
+ changedtick,
+ new_job,
+ runtime._test.hunk_cache[bufnr].hunks
+ )
+ )
+ assert.is_true(#helpers.get_extmarks(bufnr, ns) > 0)
+ end)
+end)
diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua
deleted file mode 100644
index a1b4c754..00000000
--- a/spec/commands_spec.lua
+++ /dev/null
@@ -1,4561 +0,0 @@
-local helpers = require('spec.helpers')
-
-local commands = require('diffs.commands')
-local diffspec = require('diffs.spec')
-local generated = require('diffs.generated')
-local git = require('diffs.git')
-local rails = require('diffs.rails')
-local runtime = require('diffs.runtime')
-local split = require('diffs.split')
-
-local saved_git = {}
-local saved_runtime_attach
-local saved_runtime_get_conflict_config
-local saved_runtime_get_view_config
-local saved_schedule
-local saved_systemlist
-local saved_jobstart
-local saved_jobwait
-local saved_notify
-local test_buffers = {}
-local test_repos = {}
-
-local function mock_repo_root(fn)
- saved_git.get_repo_root = git.get_repo_root
- git.get_repo_root = fn
-end
-
-local function mock_git_method(name, fn)
- saved_git[name] = git[name]
- git[name] = fn
-end
-
-local function mock_systemlist(fn)
- saved_systemlist = vim.fn.systemlist
- saved_jobstart = vim.fn.jobstart
- saved_jobwait = vim.fn.jobwait
- local exit_code
- vim.fn.jobstart = function(cmd, opts)
- local result, code, stderr = fn(cmd)
- exit_code = code or 0
- local stdout = exit_code == 0 and vim.deepcopy(result or {}) or {}
- stderr = stderr or (exit_code ~= 0 and vim.deepcopy(result or {}) or {})
- stdout[#stdout + 1] = ''
- stderr[#stderr + 1] = ''
- opts.on_stdout(1, stdout)
- opts.on_stderr(1, stderr)
- return 1
- end
- vim.fn.jobwait = function()
- return { exit_code }
- end
- vim.fn.systemlist = function(cmd)
- local result, shell_error = fn(cmd)
- if shell_error and shell_error ~= 0 then
- saved_systemlist({ 'false' })
- else
- saved_systemlist({ 'true' })
- end
- return result
- end
-end
-
-local function mock_runtime_attach(fn)
- saved_runtime_attach = saved_runtime_attach or runtime.attach
- runtime.attach = fn
- saved_schedule = saved_schedule or vim.schedule
- vim.schedule = function(callback)
- callback()
- end
-end
-
-local function mock_conflict_config(config)
- saved_runtime_get_conflict_config = runtime.get_conflict_config
- runtime.get_conflict_config = function()
- return config
- end
-end
-
-local function mock_view_config(config)
- saved_runtime_get_view_config = runtime.get_view_config
- runtime.get_view_config = function()
- return config
- end
-end
-
-local function capture_notifications()
- local notifications = {}
- saved_notify = vim.notify
- vim.notify = function(message, level)
- notifications[#notifications + 1] = { message = message, level = level }
- end
- return notifications
-end
-
-local function git_cmd(repo_root, args)
- local cmd = { 'git', '-C', repo_root }
- for _, arg in ipairs(args) do
- cmd[#cmd + 1] = arg
- end
- local output = vim.fn.systemlist(cmd)
- assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n'))
- return output
-end
-
-local function create_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- git_cmd(repo_root, { 'config', 'core.filemode', 'true' })
- vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'initial' })
-
- return repo_root
-end
-
-local function create_conflicted_repo()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- local base = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1]
-
- git_cmd(repo_root, { 'checkout', '-qb', 'ours' })
- vim.fn.writefile({ 'line 1', 'line 2 ours' }, filepath)
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'ours' })
-
- git_cmd(repo_root, { 'checkout', '-qb', 'theirs', base })
- vim.fn.writefile({ 'line 1', 'line 2 theirs' }, filepath)
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'theirs' })
-
- local output = vim.fn.systemlist({ 'git', '-C', repo_root, 'merge', 'ours' })
- assert.are_not.equal(0, vim.v.shell_error, table.concat(output, '\n'))
-
- return repo_root, filepath
-end
-
-local function write_repo_file(repo_root, path, lines)
- local filepath = repo_root .. '/' .. path
- vim.fn.mkdir(vim.fn.fnamemodify(filepath, ':h'), 'p')
- vim.fn.writefile(lines, filepath)
- return filepath
-end
-
-local function create_review_repo(opts)
- opts = opts or {}
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
-
- write_repo_file(repo_root, 'lua/one.lua', { 'old one' })
- write_repo_file(repo_root, 'lua/two.lua', {
- 'line 1',
- 'line 2',
- 'line 3',
- 'line 4',
- 'line 5',
- 'line 6',
- 'line 7',
- 'line 8',
- 'line 9',
- 'line 10',
- 'line 11',
- 'line 12',
- })
- git_cmd(repo_root, { 'add', 'lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'base' })
- local base_sha = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1]
-
- local base_ref = base_sha
- local target_ref
- if opts.named_refs then
- git_cmd(repo_root, { 'branch', 'review-base' })
- git_cmd(repo_root, { 'checkout', '-qb', 'review-topic' })
- base_ref = 'review-base'
- target_ref = 'review-topic'
- end
-
- write_repo_file(repo_root, 'lua/one.lua', { 'new one' })
- write_repo_file(repo_root, 'lua/two.lua', {
- 'line 1',
- 'line 2 changed',
- 'line 3',
- 'line 4',
- 'line 5',
- 'line 6',
- 'line 7',
- 'line 8',
- 'line 9',
- 'line 10',
- 'line 11 changed',
- 'line 12',
- })
-
- local target_sha
- if opts.commit_target ~= false then
- git_cmd(repo_root, { 'add', 'lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'target' })
- target_sha = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1]
- target_ref = target_ref or target_sha
- end
-
- return {
- repo_root = repo_root,
- base = base_ref,
- target = target_ref,
- base_sha = base_sha,
- target_sha = target_sha,
- }
-end
-
-local function create_current_state_review_repo(opts)
- opts = opts or {}
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- if opts.mnemonic_prefix then
- git_cmd(repo_root, { 'config', 'diff.mnemonicPrefix', 'true' })
- end
- if opts.noprefix then
- git_cmd(repo_root, { 'config', 'diff.noprefix', 'true' })
- end
-
- write_repo_file(repo_root, 'lua/branch.lua', { 'branch base' })
- write_repo_file(repo_root, 'lua/dup.lua', { 'dup base' })
- write_repo_file(repo_root, 'lua/staged.lua', { 'staged base' })
- write_repo_file(repo_root, 'lua/unstaged.lua', { 'unstaged base' })
- git_cmd(repo_root, { 'add', 'lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'base' })
- local base = git_cmd(repo_root, { 'rev-parse', 'HEAD' })[1]
-
- git_cmd(repo_root, { 'checkout', '-qb', 'topic' })
- write_repo_file(repo_root, 'lua/branch.lua', { 'branch head' })
- write_repo_file(repo_root, 'lua/dup.lua', { 'dup head' })
- git_cmd(repo_root, { 'add', 'lua/branch.lua', 'lua/dup.lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'branch changes' })
-
- write_repo_file(repo_root, 'lua/dup.lua', { 'dup staged' })
- write_repo_file(repo_root, 'lua/staged.lua', { 'staged changed' })
- git_cmd(repo_root, { 'add', 'lua/dup.lua', 'lua/staged.lua' })
-
- write_repo_file(repo_root, 'lua/dup.lua', { 'dup unstaged' })
- write_repo_file(repo_root, 'lua/unstaged.lua', { 'unstaged changed' })
- write_repo_file(repo_root, 'lua/new.lua', { 'new file' })
-
- return {
- repo_root = repo_root,
- base = base,
- }
-end
-
-local function create_mode_only_review_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- git_cmd(repo_root, { 'config', 'core.filemode', 'true' })
-
- write_repo_file(repo_root, 'scripts/tool.sh', { '#!/bin/sh', 'echo tool' })
- git_cmd(repo_root, { 'add', 'scripts/tool.sh' })
- git_cmd(repo_root, { 'commit', '-qm', 'base' })
- git_cmd(repo_root, { 'branch', 'mode-base' })
-
- git_cmd(repo_root, { 'checkout', '-qb', 'mode-topic' })
- git_cmd(repo_root, { 'update-index', '--chmod=+x', 'scripts/tool.sh' })
- git_cmd(repo_root, { 'commit', '-qm', 'mode' })
-
- return {
- repo_root = repo_root,
- base = 'mode-base',
- target = 'mode-topic',
- }
-end
-
-local function edit_file(path)
- vim.cmd('edit ' .. vim.fn.fnameescape(path))
- local bufnr = vim.api.nvim_get_current_buf()
- test_buffers[#test_buffers + 1] = bufnr
- return bufnr
-end
-
-local function write_binary_file(path, text)
- vim.fn.system({ 'sh', '-c', 'printf "$1" > "$2"', 'sh', text, path })
- assert.are.equal(0, vim.v.shell_error)
-end
-
-local function buffer_lines(bufnr)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- local ok, rail_width = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_rail_width')
- if ok then
- return rails.strip_lines(lines, rail_width)
- end
- return lines
-end
-
-local function buffer_text(bufnr)
- return table.concat(buffer_lines(bufnr), '\n')
-end
-
-local function quickfix_items()
- return vim.fn.getqflist({ items = 0 }).items
-end
-
-local function loclist_items(win)
- local nr = win and vim.fn.win_id2win(win) or 0
- return vim.fn.getloclist(nr, { items = 0 }).items
-end
-
-local function find_window_for_buffer(bufnr)
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- if vim.api.nvim_win_get_buf(win) == bufnr then
- return win
- end
- end
- return nil
-end
-
----@param win integer
----@return integer?
-local function find_loclist_window_for_window(win)
- local winnr = vim.fn.win_id2win(win)
- if winnr == 0 then
- return nil
- end
-
- local loclist = vim.fn.getloclist(winnr, { winid = 0 })
- if type(loclist) ~= 'table' or type(loclist.winid) ~= 'number' or loclist.winid == 0 then
- return nil
- end
- return loclist.winid
-end
-
-local function find_buffer_line(bufnr, text)
- for lnum, line in ipairs(buffer_lines(bufnr)) do
- if line:find(text, 1, true) then
- return lnum
- end
- end
- return nil
-end
-
-local function find_buffer_line_after(bufnr, after_text, text)
- local start = find_buffer_line(bufnr, after_text)
- assert.is_not_nil(start)
- local lines = buffer_lines(bufnr)
- for lnum = start + 1, #lines do
- if lines[lnum]:find(text, 1, true) then
- return lnum
- end
- end
- return nil
-end
-
-local function has_buf_var(bufnr, name)
- local ok = pcall(vim.api.nvim_buf_get_var, bufnr, name)
- return ok
-end
-
-local function restore_mocks()
- for k, v in pairs(saved_git) do
- git[k] = v
- end
- saved_git = {}
- if saved_runtime_attach then
- runtime.attach = saved_runtime_attach
- saved_runtime_attach = nil
- end
- if saved_runtime_get_conflict_config then
- runtime.get_conflict_config = saved_runtime_get_conflict_config
- saved_runtime_get_conflict_config = nil
- end
- if saved_runtime_get_view_config then
- runtime.get_view_config = saved_runtime_get_view_config
- saved_runtime_get_view_config = nil
- end
- if saved_schedule then
- vim.schedule = saved_schedule
- saved_schedule = nil
- end
- if saved_systemlist then
- vim.fn.systemlist = saved_systemlist
- saved_systemlist = nil
- end
- if saved_jobstart then
- vim.fn.jobstart = saved_jobstart
- saved_jobstart = nil
- end
- if saved_jobwait then
- vim.fn.jobwait = saved_jobwait
- saved_jobwait = nil
- end
- if saved_notify then
- vim.notify = saved_notify
- saved_notify = nil
- end
-end
-
-local function cleanup_buffers()
- for _, bufnr in ipairs(test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- test_buffers = {}
-end
-
-local function cleanup_repos()
- for _, repo_root in ipairs(test_repos) do
- vim.fn.delete(repo_root, 'rf')
- end
- test_repos = {}
-end
-
-describe('commands', function()
- after_each(function()
- restore_mocks()
- cleanup_buffers()
- cleanup_repos()
- pcall(vim.fn.setqflist, {}, 'f')
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- pcall(vim.api.nvim_set_option_value, 'winhighlight', '', { win = win })
- end
- end)
-
- describe('Diff command dispatch', function()
- local function capture_dispatch(args, vertical)
- local saved_diff = commands.diff
- local saved_review = commands.review_command
- local captured = {}
- commands.diff = function(a, v, o)
- captured.diff = { args = a, vertical = v, opts = o }
- end
- commands.review_command = function(a, v, o)
- captured.review = { args = a, vertical = v, opts = o }
- end
- commands.diff_command(args, vertical)
- commands.diff = saved_diff
- commands.review_command = saved_review
- return captured
- end
-
- it('routes plain arguments to the current-file diff', function()
- local captured = capture_dispatch('HEAD~3', false)
- assert.is_nil(captured.review)
- assert.are.same(
- { args = 'HEAD~3', vertical = false, opts = { warn_vertical_split = false } },
- captured.diff
- )
- end)
-
- it('threads the :vertical modifier and the split warning into the diff path', function()
- local captured = capture_dispatch(nil, true)
- assert.is_nil(captured.review)
- assert.are.same(
- { args = nil, vertical = true, opts = { warn_vertical_split = true } },
- captured.diff
- )
- end)
-
- it('routes a leading review subcommand to the review surface', function()
- local captured = capture_dispatch('review origin/main', true)
- assert.is_nil(captured.diff)
- assert.are.same(
- { args = 'origin/main', vertical = true, opts = { warn_vertical_split = true } },
- captured.review
- )
- end)
-
- it('treats a bare review subcommand as a review with no spec', function()
- local captured = capture_dispatch('review', false)
- assert.is_nil(captured.diff)
- assert.are.same(
- { args = nil, vertical = false, opts = { warn_vertical_split = false } },
- captured.review
- )
- end)
-
- it('only treats review as the subcommand when it is the first token', function()
- local captured = capture_dispatch('++layout=split review', false)
- assert.is_nil(captured.review)
- assert.are.same(
- { args = '++layout=split review', vertical = false, opts = { warn_vertical_split = false } },
- captured.diff
- )
- end)
- end)
-
- describe('generated rail style metadata', function()
- local function diff_lines(change)
- return {
- 'diff --git a/lua/foo.lua b/lua/foo.lua',
- '--- a/lua/foo.lua',
- '+++ b/lua/foo.lua',
- '@@ -1,2 +1,3 @@',
- ' local M = {}',
- '+' .. change,
- ' return M',
- }
- end
-
- local function assert_single_rail_display(bufnr, change)
- local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-
- assert.are.equal(8, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_width'))
- assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style'))
- assert.are.equal('single', rails.style_for_buffer(bufnr))
- assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1])
- assert.is_true(table.concat(display_lines, '\n'):find(' 2 │ +' .. change, 1, true) ~= nil)
- end
-
- it('creates single-rail generated buffers and keeps hunk metadata raw', function()
- mock_view_config({ prefix = true })
-
- local diff_spec = diffspec.index_to_worktree('lua/foo.lua')
- local bufnr = commands._test.create_generated_diff_buffer({
- name = 'diffs://unstaged:lua/foo.lua',
- lines = diff_lines('local x = 1'),
- repo_root = '/tmp/repo',
- diff_spec = diff_spec,
- source = generated.file_source('/tmp/repo', diff_spec),
- rail_style = 'single',
- })
- table.insert(test_buffers, bufnr)
-
- assert_single_rail_display(bufnr, 'local x = 1')
-
- local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', diff_hunks[1].file_header_lines[1])
- assert.are.equal('@@ -1,2 +1,3 @@', diff_hunks[1].header)
- assert.are.equal('@@ -1,2 +1,3 @@', diff_hunks[1].lines[1].text)
- assert.are.equal(' local M = {}', diff_hunks[1].lines[2].text)
- assert.are.equal('+local x = 1', diff_hunks[1].lines[3].text)
- assert.are.equal(' return M', diff_hunks[1].lines[4].text)
- end)
-
- it('preserves single rails when generated buffers reload without an explicit style', function()
- mock_view_config({ prefix = true })
- mock_runtime_attach(function() end)
- mock_systemlist(function(cmd)
- assert.are.same({
- 'git',
- '-C',
- '/tmp/repo',
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- }, cmd)
- return diff_lines('local reloaded = true')
- end)
-
- local bufnr = commands._test.create_generated_diff_buffer({
- name = 'diffs://unstaged:all',
- lines = diff_lines('local stale = true'),
- repo_root = '/tmp/repo',
- source = generated.section_source('/tmp/repo', 'unstaged'),
- rail_style = 'single',
- })
- table.insert(test_buffers, bufnr)
-
- commands.read_buffer(bufnr)
-
- assert_single_rail_display(bufnr, 'local reloaded = true')
- assert.is_true(buffer_text(bufnr):find('+local reloaded = true', 1, true) ~= nil)
- assert.is_false(buffer_text(bufnr):find('+local stale = true', 1, true) ~= nil)
- end)
-
- it('lets generated buffer replacement override an existing rail style', function()
- mock_view_config({ prefix = true })
-
- local bufnr = commands._test.create_generated_diff_buffer({
- name = 'diffs://unstaged:all',
- lines = diff_lines('local single = true'),
- repo_root = '/tmp/repo',
- rail_style = 'single',
- })
- table.insert(test_buffers, bufnr)
-
- commands._test.replace_generated_diff_buffer_lines(
- bufnr,
- diff_lines('local dual = true'),
- nil,
- { rail_style = 'dual' }
- )
-
- local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(10, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_width'))
- assert.are.equal('dual', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style'))
- assert.are.equal('dual', rails.style_for_buffer(bufnr))
- assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1])
- assert.is_true(
- table.concat(display_lines, '\n'):find(' 2 │ +local dual = true', 1, true) ~= nil
- )
- end)
- end)
-
- describe('command completion', function()
- it('completes the Diff subcommands, layout options, objects, and refs', function()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_systemlist(function()
- return { 'origin/main', 'feature/topic' }
- end)
-
- assert.are.same({
- 'review',
- 'files',
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
- ':',
- ':%',
- ':0:%',
- '@:%',
- 'origin/main',
- 'feature/topic',
- }, commands._test.complete_diff('', 'Diff ', #'Diff '))
-
- assert.are.same({ 'review' }, commands._test.complete_diff('rev', 'Diff rev', #'Diff rev'))
-
- assert.are.same({ 'files' }, commands._test.complete_diff('fil', 'Diff fil', #'Diff fil'))
-
- assert.are.same({
- '++layout=unified',
- '++layout=stacked',
- }, commands._test.complete_diff('++l', 'Diff files ++l', #'Diff files ++l'))
-
- assert.are.same({
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
- }, commands._test.complete_diff('++l', 'Diff ++l', #'Diff ++l'))
-
- assert.are.same({
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
- '++nountracked',
- 'origin/main',
- 'feature/topic',
- }, commands._test.complete_diff('', 'Diff review ', #'Diff review '))
-
- assert.are.same(
- {
- '++nountracked',
- 'origin/main',
- 'feature/topic',
- },
- commands._test.complete_diff(
- '',
- 'Diff review ++layout=split ',
- #'Diff review ++layout=split '
- )
- )
-
- assert.are.same(
- { 'origin/main', 'feature/topic' },
- commands._test.complete_diff('', 'Diff review origin/main ', #'Diff review origin/main ')
- )
- assert.are.same(
- { 'origin/main', 'feature/topic' },
- commands._test.complete_diff(
- '',
- 'Diff review ++layout=split origin/main ',
- #'Diff review ++layout=split origin/main '
- )
- )
- assert.are.same({}, commands._test.complete_diff('', 'Diff HEAD ', #'Diff HEAD '))
- end)
- end)
-
- describe('filter_combined_diffs', function()
- it('strips diff --cc entries entirely', function()
- local lines = {
- 'diff --cc main.lua',
- 'index d13ab94,b113aee..0000000',
- '--- a/main.lua',
- '+++ b/main.lua',
- '@@@ -1,7 -1,7 +1,11 @@@',
- ' local M = {}',
- '++<<<<<<< HEAD',
- ' + return 1',
- '++=======',
- '+ return 2',
- '++>>>>>>> theirs',
- ' end',
- }
- local result = commands.filter_combined_diffs(lines)
- assert.are.equal(0, #result)
- end)
-
- it('preserves diff --git entries', function()
- local lines = {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,3 @@',
- ' local M = {}',
- '-local x = 1',
- '+local x = 2',
- ' return M',
- }
- local result = commands.filter_combined_diffs(lines)
- assert.are.equal(8, #result)
- assert.are.same(lines, result)
- end)
-
- it('strips combined but keeps unified in mixed output', function()
- local lines = {
- 'diff --cc conflict.lua',
- 'index aaa,bbb..000',
- '@@@ -1,1 -1,1 +1,5 @@@',
- '++<<<<<<< HEAD',
- 'diff --git a/clean.lua b/clean.lua',
- '--- a/clean.lua',
- '+++ b/clean.lua',
- '@@ -1,1 +1,1 @@',
- '-old',
- '+new',
- }
- local result = commands.filter_combined_diffs(lines)
- assert.are.equal(6, #result)
- assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1])
- assert.are.equal('+new', result[6])
- end)
-
- it('returns empty for empty input', function()
- local result = commands.filter_combined_diffs({})
- assert.are.equal(0, #result)
- end)
-
- it('returns empty when all entries are combined', function()
- local lines = {
- 'diff --cc a.lua',
- 'some content',
- 'diff --cc b.lua',
- 'more content',
- }
- local result = commands.filter_combined_diffs(lines)
- assert.are.equal(0, #result)
- end)
- end)
-
- describe('diff DiffSpec rendering', function()
- local function create_split_source(opts)
- opts = opts or {}
- local filepath = opts.filepath or '/tmp/repo/lua/foo.lua'
- local relpath = opts.relpath or 'lua/foo.lua'
- local worktree_lines = opts.worktree_lines
- or {
- 'local M = {}',
- 'local x = 1',
- 'return M',
- }
- local index_lines = opts.index_lines or { 'local M = {}', 'return M' }
-
- local source_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, source_buf)
- vim.api.nvim_buf_set_name(source_buf, filepath)
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, worktree_lines)
- vim.api.nvim_set_option_value('filetype', 'lua', { buf = source_buf })
- vim.api.nvim_set_current_buf(source_buf)
-
- mock_git_method('get_relative_path', function(actual_filepath)
- assert.are.equal(filepath, actual_filepath)
- return relpath
- end)
- mock_git_method('get_index_content', function(actual_filepath)
- assert.are.equal(filepath, actual_filepath)
- return index_lines
- end)
- mock_repo_root(function(actual_filepath)
- assert.are.equal(filepath, actual_filepath)
- return '/tmp/repo'
- end)
-
- return source_buf
- end
-
- local function find_split_windows(left_buf, right_buf)
- local left_win
- local right_win
- local left_index
- local right_index
- for i, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local win_buf = vim.api.nvim_win_get_buf(win)
- if win_buf == left_buf then
- left_win = win
- left_index = i
- elseif win_buf == right_buf then
- right_win = win
- right_index = i
- end
- end
-
- return left_win, right_win, left_index, right_index
- end
-
- it('opens default :Diff as an unstaged index to worktree diff', function()
- local source_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, source_buf)
- vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, {
- 'local M = {}',
- 'local x = 1',
- 'return M',
- })
- vim.api.nvim_set_current_buf(source_buf)
-
- local called_index = false
- local called_head = false
- mock_git_method('get_relative_path', function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return 'lua/foo.lua'
- end)
- mock_git_method('get_index_content', function(filepath)
- called_index = true
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_git_method('get_file_content', function()
- called_head = true
- return { 'should not be used' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
- mock_view_config({ prefix = true })
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local lines = buffer_lines(diff_buf)
-
- assert.is_true(called_index)
- assert.is_false(called_head)
- assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.index_to_worktree('lua/foo.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('lua/foo.lua', diff_hunks[1].file)
- assert.is_true(diff_hunks[1].can_put)
- assert.is_false(diff_hunks[1].can_obtain)
- assert.is_true(diff_hunks[1].actionable)
- assert.are.equal('worktree', diff_hunks[1].mutation_target)
- assert.is_true(helpers.has_keymap(diff_buf, ']c'))
- assert.is_true(helpers.has_keymap(diff_buf, '[c'))
- assert.is_true(helpers.has_keymap(diff_buf, ''))
- assert.is_false(helpers.has_keymap(diff_buf, 'o'))
- assert.is_false(helpers.has_keymap(diff_buf, 'do'))
- assert.is_true(helpers.has_keymap(diff_buf, 'dp'))
- assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', lines[1])
- assert.is_true(table.concat(lines, '\n'):find('+local x = 1', 1, true) ~= nil)
-
- local display_lines = vim.api.nvim_buf_get_lines(diff_buf, 0, -1, false)
- assert.are.equal(10, vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_width'))
- assert.are.equal('dual', vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_style'))
- assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1])
- assert.is_true(
- table.concat(display_lines, '\n'):find(' 2 │ +local x = 1', 1, true) ~= nil
- )
-
- local qf = quickfix_items()
- assert.are.equal(0, #qf)
-
- local loc = loclist_items()
- assert.are.equal(1, #loc)
- assert.are.equal(diff_buf, loc[1].bufnr)
- assert.are.equal(diff_hunks[1].buffer_range.start, loc[1].lnum)
- assert.is_true(loc[1].text:find('lua/foo.lua', 1, true) ~= nil)
- end)
-
- it('leaves an existing user quickfix list untouched for a single-file diff', function()
- local source_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, source_buf)
- vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, {
- 'local M = {}',
- 'local x = 1',
- 'return M',
- })
- vim.api.nvim_set_current_buf(source_buf)
-
- mock_git_method('get_relative_path', function()
- return 'lua/foo.lua'
- end)
- mock_git_method('get_index_content', function()
- return { 'local M = {}', 'return M' }
- end)
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
- mock_view_config({ prefix = true })
-
- vim.fn.setqflist({}, ' ', {
- title = 'user list',
- items = {
- { filename = '/tmp/repo/other.lua', lnum = 3, text = 'grep hit' },
- { filename = '/tmp/repo/more.lua', lnum = 7, text = 'grep hit two' },
- },
- })
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- local qf = vim.fn.getqflist({ title = 0, items = 0 })
- assert.are.equal('user list', qf.title)
- assert.are.equal(2, #qf.items)
-
- local loc = loclist_items()
- assert.is_true(#loc >= 1)
- assert.are.equal(diff_buf, loc[1].bufnr)
- end)
-
- it('opens stacked :Diff as a generated buffer with single rails', function()
- mock_runtime_attach(function() end)
- mock_view_config({ prefix = true })
- create_split_source()
-
- commands.diff('++layout=stacked', false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local display_lines = vim.api.nvim_buf_get_lines(diff_buf, 0, -1, false)
-
- assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.index_to_worktree('lua/foo.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- assert.are.equal(8, vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_width'))
- assert.are.equal('single', vim.api.nvim_buf_get_var(diff_buf, 'diffs_rail_style'))
- assert.are.equal('single', rails.style_for_buffer(diff_buf))
- assert.are.equal(' │ diff --git a/lua/foo.lua b/lua/foo.lua', display_lines[1])
- assert.is_true(table.concat(display_lines, '\n'):find(' 2 │ +local x = 1', 1, true) ~= nil)
- assert.is_false(has_buf_var(diff_buf, 'diffs_split_peer'))
-
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win))
- assert.is_false(name:match('^diffs://split:') ~= nil)
- end
- end)
-
- it('opens default :Diff for untracked files as index to worktree additions', function()
- local source_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, source_buf)
- vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/new.lua')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, {
- 'local M = {}',
- 'return M',
- })
- vim.api.nvim_set_current_buf(source_buf)
-
- mock_git_method('get_relative_path', function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return 'lua/new.lua'
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return nil, 'file not in index'
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local lines = buffer_lines(diff_buf)
-
- assert.are.equal('diffs://unstaged:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.index_to_worktree('lua/new.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) ~= nil)
- assert.is_true(text:find('+local M = {}', 1, true) ~= nil)
- end)
-
- it('opens configured default :Diff as paired endpoint windows', function()
- local saved_splitright = vim.o.splitright
- vim.o.splitright = false
- local ok, err = pcall(function()
- mock_view_config({
- default_layout = 'split',
- prefix = true,
- })
- create_split_source()
- commands.diff(nil, false)
- end)
- vim.o.splitright = saved_splitright
- assert.is_true(ok, err)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- test_buffers[#test_buffers + 1] = left_buf
- test_buffers[#test_buffers + 1] = right_buf
-
- assert.are.equal('diffs://split:left:index:lua/foo.lua', vim.api.nvim_buf_get_name(left_buf))
- assert.are.equal(
- 'diffs://split:right:worktree:lua/foo.lua',
- vim.api.nvim_buf_get_name(right_buf)
- )
- end)
-
- it('opens opt-in split :Diff as paired endpoint windows', function()
- local saved_splitright = vim.o.splitright
- vim.o.splitright = false
- local ok, err = pcall(function()
- mock_view_config({ prefix = true })
- create_split_source()
- commands.diff('++layout=split', false)
- end)
- vim.o.splitright = saved_splitright
- assert.is_true(ok, err)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win, left_index, right_index = find_split_windows(left_buf, right_buf)
-
- assert.is_not_nil(left_win)
- assert.is_not_nil(right_win)
- assert.is_true(left_index < right_index)
- assert.are.equal('diffs://split:left:index:lua/foo.lua', vim.api.nvim_buf_get_name(left_buf))
- assert.are.equal(
- 'diffs://split:right:worktree:lua/foo.lua',
- vim.api.nvim_buf_get_name(right_buf)
- )
- local left_lines = vim.api.nvim_buf_get_lines(left_buf, 0, -1, false)
- local right_lines = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false)
- assert.are.equal(#left_lines, #right_lines)
- assert.are.equal('local M = {}', left_lines[1])
- assert.are.equal('local M = {}', right_lines[1])
- assert.are.equal('local x = 1', right_lines[2])
- assert.is_true(vim.tbl_contains(left_lines, ''))
- assert.are.same(
- diffspec.index_to_worktree('lua/foo.lua'),
- vim.api.nvim_buf_get_var(left_buf, 'diffs_source').spec
- )
- assert.are.equal('left', vim.api.nvim_buf_get_var(left_buf, 'diffs_source').side)
- assert.are.equal('right', vim.api.nvim_buf_get_var(right_buf, 'diffs_source').side)
- assert.are.equal('lua', vim.api.nvim_get_option_value('filetype', { buf = left_buf }))
- assert.are.equal('lua', vim.api.nvim_get_option_value('filetype', { buf = right_buf }))
- assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = left_buf }))
- assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = right_buf }))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = left_win }))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win }))
- assert.is_false(vim.api.nvim_get_option_value('number', { win = left_win }))
- assert.is_false(vim.api.nvim_get_option_value('number', { win = right_win }))
- assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = left_win }) ~= '')
- assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = right_win }) ~= '')
- assert.are.equal('manual', vim.api.nvim_get_option_value('foldmethod', { win = left_win }))
- assert.are.equal('manual', vim.api.nvim_get_option_value('foldmethod', { win = right_win }))
- assert.is_false(vim.api.nvim_get_option_value('foldenable', { win = left_win }))
- assert.is_false(vim.api.nvim_get_option_value('foldenable', { win = right_win }))
- assert.are.equal('0', vim.api.nvim_get_option_value('foldcolumn', { win = left_win }))
- assert.are.equal('0', vim.api.nvim_get_option_value('foldcolumn', { win = right_win }))
- assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = left_win }))
- assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = right_win }))
- assert.is_true(vim.api.nvim_get_option_value('cursorbind', { win = left_win }))
- assert.is_true(vim.api.nvim_get_option_value('cursorbind', { win = right_win }))
- assert.is_true(helpers.has_keymap(left_buf, 'q'))
- assert.is_true(helpers.has_keymap(right_buf, 'q'))
- assert.is_true(helpers.has_keymap(left_buf, ''))
- assert.is_true(helpers.has_keymap(right_buf, ''))
- assert.is_true(helpers.has_keymap(left_buf, ']c'))
- assert.is_true(helpers.has_keymap(right_buf, ']c'))
- assert.is_true(helpers.has_keymap(left_buf, '[c'))
- assert.is_true(helpers.has_keymap(right_buf, '[c'))
- assert.is_false(helpers.has_keymap(right_buf, 'dp'))
- assert.is_false(helpers.has_keymap(right_buf, 'do'))
-
- local line_ns = vim.api.nvim_get_namespaces().diffs_split_line
- local right_bg = vim.api.nvim_buf_get_extmarks(right_buf, line_ns, 0, -1, { details = true })
- local has_added_line_bg = false
- for _, mark in ipairs(right_bg) do
- if mark[2] == 1 and mark[4].hl_group == 'DiffsAdd' and mark[4].hl_eol then
- has_added_line_bg = true
- end
- end
- assert.is_true(has_added_line_bg)
-
- vim.g.statusline_winid = right_win
- vim.v.lnum = 2
- local rail = require('diffs.split').statuscolumn()
- assert.is_true(rail:find('DiffsAddBar', 1, true) ~= nil)
- assert.is_true(rail:find('▏', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(0, #qf)
-
- local left_loc = loclist_items(left_win)
- local right_loc = loclist_items(right_win)
- assert.are.equal(1, #left_loc)
- assert.are.equal(1, #right_loc)
- assert.are.equal(left_buf, left_loc[1].bufnr)
- assert.are.equal(right_buf, right_loc[1].bufnr)
- end)
-
- it('paints the configured intra word-diff overlay on both split panes', function()
- mock_view_config({ prefix = true })
- create_split_source({
- index_lines = { 'function f()', ' return 111', ' log()', 'end' },
- worktree_lines = { 'function f()', ' return 222', ' log()', 'end' },
- })
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
-
- local intra_ns = vim.api.nvim_get_namespaces().diffs_split_intra
- assert.is_not_nil(intra_ns)
-
- local function intra_mark(bufnr, group)
- for _, mark in
- ipairs(vim.api.nvim_buf_get_extmarks(bufnr, intra_ns, 0, -1, { details = true }))
- do
- if mark[4].hl_group == group then
- return mark
- end
- end
- return nil
- end
-
- local left_mark = intra_mark(left_buf, 'DiffsDeleteText')
- local right_mark = intra_mark(right_buf, 'DiffsAddText')
- assert.is_not_nil(left_mark)
- assert.is_not_nil(right_mark)
- assert.are.equal(1, left_mark[2])
- assert.are.equal(9, left_mark[3])
- assert.are.equal(12, left_mark[4].end_col)
- assert.are.equal(1, right_mark[2])
- assert.are.equal(9, right_mark[3])
- assert.are.equal(12, right_mark[4].end_col)
-
- local line_ns = vim.api.nvim_get_namespaces().diffs_split_line
- assert.is_true(#vim.api.nvim_buf_get_extmarks(left_buf, line_ns, { 1, 0 }, { 1, -1 }, {}) > 0)
- assert.is_true(
- #vim.api.nvim_buf_get_extmarks(right_buf, line_ns, { 1, 0 }, { 1, -1 }, {}) > 0
- )
- end)
-
- it('leaves split panes untouched when attach_diff re-runs', function()
- mock_view_config({ prefix = true })
- create_split_source()
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- runtime.attach_diff()
-
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = left_win }))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win }))
- assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = left_win }) ~= '')
- assert.is_true(vim.api.nvim_get_option_value('statuscolumn', { win = right_win }) ~= '')
- end)
-
- it('renders real per-side line numbers through the statuscolumn rail', function()
- mock_view_config({ prefix = true })
- create_split_source({
- index_lines = { 'a', 'b', 'c', 'd' },
- worktree_lines = { 'X', 'a', 'b', 'c', 'd' },
- })
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- vim.wo[left_win].number = true
- vim.wo[right_win].number = true
-
- local function rail(win, lnum)
- local sc = vim.api.nvim_get_option_value('statuscolumn', { win = win })
- return vim.api.nvim_eval_statusline(
- sc,
- { winid = win, use_statuscol_lnum = lnum, highlights = false }
- ).str
- end
-
- assert.are.equal(
- vim.api.nvim_buf_line_count(left_buf),
- vim.api.nvim_buf_line_count(right_buf)
- )
- assert.is_true(#rail(right_win, 1) > 0)
- assert.is_true(rail(right_win, 1):find('1', 1, true) ~= nil)
- assert.is_true(rail(left_win, 2):find('1', 1, true) ~= nil)
- assert.is_true(rail(right_win, 2):find('2', 1, true) ~= nil)
- assert.is_true(rail(right_win, 2):find('1', 1, true) == nil)
-
- vim.wo[right_win].relativenumber = true
- assert.is_true(rail(right_win, 2):find('2', 1, true) ~= nil)
-
- vim.wo[right_win].number = false
- assert.is_nil(rail(right_win, 2):find('%d'))
- end)
-
- for _, exit in ipairs({ 'replace', 'close' }) do
- it('preserves window defaults when leaving a split via ' .. exit, function()
- vim.cmd.tabnew()
- local tab = vim.api.nvim_get_current_tabpage()
- local ok, err = pcall(function()
- create_split_source()
- local defaults = {
- statuscolumn = 'ordinary:%l',
- number = true,
- scrollbind = false,
- cursorbind = false,
- wrap = true,
- foldmethod = 'marker',
- foldenable = true,
- }
- for name, value in pairs(defaults) do
- vim.api.nvim_set_option_value(name, value, { win = 0 })
- end
- vim.api.nvim_set_option_value('statuscolumn', 'source:%l', {
- win = 0,
- scope = 'local',
- })
-
- commands.diff('++layout=split', false)
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.b[right_buf].diffs_split_peer
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
- for _, win in ipairs({ left_win, right_win }) do
- for name, value in pairs(defaults) do
- assert.are.equal(
- value,
- vim.api.nvim_get_option_value(name, { win = win, scope = 'global' }),
- name
- )
- end
- end
-
- if exit == 'replace' then
- vim.cmd.enew()
- else
- assert.is_true(split.close_pair(right_buf))
- end
- for name, value in pairs(defaults) do
- assert.are.equal(value, vim.api.nvim_get_option_value(name, { win = 0 }), name)
- assert.are.equal(
- value,
- vim.api.nvim_get_option_value(name, { win = 0, scope = 'global' }),
- name
- )
- end
- assert.are.equal(
- 'ordinary:1',
- vim.api.nvim_eval_statusline(vim.wo.statuscolumn, { use_statuscol_lnum = 1 }).str
- )
- end)
- if vim.api.nvim_tabpage_is_valid(tab) then
- vim.api.nvim_set_current_tabpage(tab)
- vim.cmd.tabclose({ bang = true })
- end
- assert.is_true(ok, err)
- end)
- end
-
- it('warns and still opens the split when :vertical Diff ++layout=split is used', function()
- local notifications = capture_notifications()
- local saved_splitright = vim.o.splitright
- vim.o.splitright = false
- local ok, err = pcall(function()
- mock_view_config({ prefix = true })
- create_split_source()
- commands.diff_command('++layout=split', true)
- end)
- vim.o.splitright = saved_splitright
- assert.is_true(ok, err)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
- assert.is_not_nil(left_win)
- assert.is_not_nil(right_win)
-
- local warned = false
- for _, n in ipairs(notifications) do
- if tostring(n.message):find('++layout=split ignores the :vertical modifier', 1, true) then
- warned = true
- end
- end
- assert.is_true(warned)
- end)
-
- it('moves split hunk navigation through both endpoint windows', function()
- create_split_source({
- index_lines = {
- 'line 1',
- 'line 2',
- 'line 3',
- 'line 4',
- 'line 5',
- 'line 6',
- 'line 7',
- 'line 8',
- 'line 9',
- 'line 10',
- 'line 11',
- 'line 12',
- },
- worktree_lines = {
- 'line 1',
- 'line 2 changed',
- 'line 3',
- 'line 4',
- 'line 5',
- 'line 6',
- 'line 7',
- 'line 8',
- 'line 9',
- 'line 10',
- 'line 11 changed',
- 'line 12',
- },
- })
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- local right_loc = loclist_items(right_win)
- assert.are.equal(2, #right_loc)
- assert.are.equal(right_buf, right_loc[2].bufnr)
-
- local function synced_row()
- local lrow = vim.api.nvim_win_get_cursor(left_win)[1]
- local rrow = vim.api.nvim_win_get_cursor(right_win)[1]
- assert.are.equal(lrow, rrow)
- return rrow
- end
- local function in_hunk(row, item)
- return row == item.lnum
- end
-
- vim.api.nvim_set_current_win(right_win)
- vim.api.nvim_win_set_cursor(right_win, { 1, 0 })
-
- split.goto_next(right_buf)
- assert.is_true(in_hunk(synced_row(), right_loc[1]))
-
- split.goto_next(right_buf)
- assert.is_true(in_hunk(synced_row(), right_loc[2]))
-
- split.goto_prev(right_buf)
- assert.is_true(in_hunk(synced_row(), right_loc[1]))
-
- vim.api.nvim_set_current_win(right_win)
- vim.cmd('lopen')
- local found_qf = false
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_get_option_value('buftype', { buf = buf }) == 'quickfix' then
- vim.api.nvim_set_current_win(win)
- found_qf = true
- break
- end
- end
- assert.is_true(found_qf)
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
- vim.cmd('normal \r')
- vim.wait(100, function()
- return vim.api.nvim_win_get_cursor(left_win)[1] == right_loc[2].lnum
- end)
-
- assert.are.equal(right_loc[2].lnum, vim.api.nvim_win_get_cursor(left_win)[1])
- assert.are.equal(
- vim.api.nvim_win_get_cursor(left_win)[1],
- vim.api.nvim_win_get_cursor(right_win)[1]
- )
- end)
-
- it('targets the old endpoint for split deleted hunks in the loclist', function()
- create_split_source({
- index_lines = {
- 'line 1',
- 'line 2',
- 'line 3',
- },
- worktree_lines = {},
- })
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- local qf = quickfix_items()
- assert.are.equal(0, #qf)
-
- local right_loc = loclist_items(right_win)
- assert.are.equal(1, #right_loc)
- assert.are.equal(left_buf, right_loc[1].bufnr)
- assert.are.equal(1, right_loc[1].lnum)
-
- vim.api.nvim_set_current_win(right_win)
- vim.cmd('lopen')
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_get_option_value('buftype', { buf = buf }) == 'quickfix' then
- vim.api.nvim_set_current_win(win)
- break
- end
- end
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
- vim.cmd('normal \r')
- vim.wait(100, function()
- return vim.api.nvim_get_current_buf() == left_buf
- and vim.api.nvim_win_get_cursor(left_win)[1] == right_loc[1].lnum
- end)
-
- assert.are.equal(left_buf, vim.api.nvim_get_current_buf())
- assert.are.same({ right_loc[1].lnum, 0 }, vim.api.nvim_win_get_cursor(left_win))
- end)
-
- it('opens the worktree split endpoint in an existing source window', function()
- local notifications = capture_notifications()
- local source_buf = create_split_source()
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- vim.api.nvim_set_current_win(right_win)
- vim.cmd('belowright split')
- local source_win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(source_win, source_buf)
-
- vim.api.nvim_set_current_win(right_win)
- vim.api.nvim_win_set_cursor(right_win, { 2, 0 })
- assert.is_true(split.open_source(right_buf))
-
- assert.are.equal(source_win, vim.api.nvim_get_current_win())
- assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(source_win))
-
- vim.api.nvim_set_current_win(left_win)
- assert.is_false(split.open_source(left_buf))
- assert.is_true(
- notifications[#notifications].message:find('index-backed split endpoint', 1, true) ~= nil
- )
- end)
-
- it(
- 'opens the worktree split endpoint in the invoking window when no source window exists',
- function()
- local source_buf = create_split_source()
- vim.api.nvim_set_option_value('diff', false, { win = 0 })
- vim.api.nvim_set_option_value('scrollbind', false, { win = 0 })
- vim.api.nvim_set_option_value('cursorbind', false, { win = 0 })
- vim.api.nvim_set_option_value('foldenable', true, { win = 0 })
-
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win, right_win = find_split_windows(left_buf, right_buf)
-
- vim.api.nvim_set_current_win(right_win)
- vim.api.nvim_win_set_cursor(right_win, { 2, 0 })
- assert.is_true(split.open_source(right_buf))
-
- assert.are.equal(right_win, vim.api.nvim_get_current_win())
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(right_win))
- assert.is_false(vim.api.nvim_buf_is_valid(left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(right_buf))
- assert.is_false(vim.api.nvim_win_is_valid(left_win))
- assert.is_true(vim.api.nvim_win_is_valid(right_win))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = right_win }))
- assert.is_false(vim.api.nvim_get_option_value('scrollbind', { win = right_win }))
- assert.is_false(vim.api.nvim_get_option_value('cursorbind', { win = right_win }))
- assert.is_true(vim.api.nvim_get_option_value('foldenable', { win = right_win }))
- end
- )
-
- it('cleans up the peer endpoint when one split buffer is wiped', function()
- create_split_source()
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
-
- vim.api.nvim_buf_delete(left_buf, { force = true })
-
- vim.wait(100, function()
- return not vim.api.nvim_buf_is_valid(right_buf)
- end)
-
- assert.is_false(vim.api.nvim_buf_is_valid(left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(right_buf))
- end)
-
- it('tears down the pair when a pane window is closed', function()
- create_split_source()
- vim.cmd('split')
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local _, right_win = find_split_windows(left_buf, right_buf)
-
- vim.api.nvim_win_close(right_win, false)
- vim.wait(200, function()
- return not vim.api.nvim_buf_is_valid(left_buf)
- end)
-
- assert.is_false(vim.api.nvim_buf_is_valid(left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(right_buf))
- for _, buf in ipairs(vim.api.nvim_list_bufs()) do
- if vim.api.nvim_buf_is_valid(buf) then
- assert.is_nil(vim.api.nvim_buf_get_name(buf):match('diffs://split:'))
- end
- end
- end)
-
- it('tears down the pair when :only is run on a pane', function()
- create_split_source()
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local left_win = find_split_windows(left_buf, right_buf)
-
- vim.api.nvim_set_current_win(left_win)
- vim.cmd('only')
- vim.wait(200, function()
- return not vim.api.nvim_buf_is_valid(right_buf)
- end)
-
- assert.is_false(vim.api.nvim_buf_is_valid(left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(right_buf))
- assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 1)
- end)
-
- it('closes paired split endpoint buffers with q and can reopen the same split', function()
- local source_buf = create_split_source()
-
- commands.diff('++layout=split', false)
-
- local right_buf = vim.api.nvim_get_current_buf()
- local left_buf = vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer')
- table.insert(test_buffers, left_buf)
- table.insert(test_buffers, right_buf)
- local _, right_win = find_split_windows(left_buf, right_buf)
-
- vim.api.nvim_set_current_win(right_win)
- vim.cmd('normal q')
-
- assert.is_false(vim.api.nvim_buf_is_valid(left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(right_buf))
-
- vim.api.nvim_set_current_buf(source_buf)
- assert.has_no.errors(function()
- commands.diff('++layout=split', false)
- end)
-
- local reopened_right_buf = vim.api.nvim_get_current_buf()
- local reopened_left_buf = vim.api.nvim_buf_get_var(reopened_right_buf, 'diffs_split_peer')
- table.insert(test_buffers, reopened_left_buf)
- table.insert(test_buffers, reopened_right_buf)
-
- assert.are.equal(
- 'diffs://split:left:index:lua/foo.lua',
- vim.api.nvim_buf_get_name(reopened_left_buf)
- )
- assert.are.equal(
- 'diffs://split:right:worktree:lua/foo.lua',
- vim.api.nvim_buf_get_name(reopened_right_buf)
- )
- end)
-
- it('reopens stale split endpoint buffers from the invoking source window', function()
- local source_buf = create_split_source()
- commands.diff('++layout=split', false)
-
- local old_right_buf = vim.api.nvim_get_current_buf()
- local old_left_buf = vim.api.nvim_buf_get_var(old_right_buf, 'diffs_split_peer')
- table.insert(test_buffers, old_left_buf)
- table.insert(test_buffers, old_right_buf)
-
- local scratch_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, scratch_buf)
- vim.cmd('topleft split')
- local scratch_win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(scratch_win, scratch_buf)
-
- vim.cmd('botright split')
- local invoking_win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(invoking_win, source_buf)
-
- commands.diff('++layout=split', false)
-
- local reopened_right_buf = vim.api.nvim_get_current_buf()
- local reopened_left_buf = vim.api.nvim_buf_get_var(reopened_right_buf, 'diffs_split_peer')
- table.insert(test_buffers, reopened_left_buf)
- table.insert(test_buffers, reopened_right_buf)
- local reopened_left_win, reopened_right_win =
- find_split_windows(reopened_left_buf, reopened_right_buf)
-
- assert.is_false(vim.api.nvim_buf_is_valid(old_left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(old_right_buf))
- assert.are.equal(invoking_win, reopened_left_win)
- assert.are.equal(reopened_right_win, vim.api.nvim_get_current_win())
- assert.are.equal(scratch_buf, vim.api.nvim_win_get_buf(scratch_win))
- end)
-
- it('preserves the explicit revision generated buffer surface', function()
- local source_buf = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, source_buf)
- vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, {
- 'local M = {}',
- 'local x = 1',
- 'return M',
- })
- vim.api.nvim_set_current_buf(source_buf)
-
- local captured_revision
- mock_git_method('get_relative_path', function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return 'lua/foo.lua'
- end)
- mock_git_method('get_file_content', function(revision, filepath)
- captured_revision = revision
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff('HEAD~3', false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local lines = buffer_lines(diff_buf)
-
- assert.are.equal('HEAD~3', captured_revision)
- assert.are.equal('diffs://HEAD~3:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.equal('/tmp/repo', vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').repo_root)
- assert.are.same(
- diffspec.rev_to_worktree('HEAD~3', 'lua/foo.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('lua/foo.lua', diff_hunks[1].file)
- assert.is_false(diff_hunks[1].can_put)
- assert.is_false(diff_hunks[1].can_obtain)
- assert.is_false(diff_hunks[1].actionable)
- assert.are.equal('worktree', diff_hunks[1].mutation_target)
- assert.are.equal('diff --git a/lua/foo.lua b/lua/foo.lua', lines[1])
- assert.are.equal('--- a/lua/foo.lua', lines[2])
- assert.are.equal('+++ b/lua/foo.lua', lines[3])
- assert.is_true(table.concat(lines, '\n'):find('+local x = 1', 1, true) ~= nil)
- end)
- end)
-
- describe('diff real repository rendering', function()
- for _, layout in ipairs({ 'unified', 'stacked', 'split' }) do
- it('reloads live worktree content in ' .. layout .. ' views', function()
- local repo_root = create_repo()
- local source_buf = edit_file(repo_root .. '/file.txt')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { 'unsaved first' })
- mock_runtime_attach(function() end)
- commands.diff('++layout=' .. layout, false)
- local diff_buf = vim.api.nvim_get_current_buf()
- test_buffers[#test_buffers + 1] = diff_buf
- if layout == 'split' then
- local peer = vim.b[diff_buf].diffs_split_peer
- test_buffers[#test_buffers + 1] = peer
- if vim.b[diff_buf].diffs_source.side == 'left' then
- diff_buf = peer
- end
- end
- assert.is_true(buffer_text(diff_buf):find('unsaved first', 1, true) ~= nil)
-
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, { 'unsaved second' })
- commands.refresh(diff_buf)
- assert.is_true(buffer_text(diff_buf):find('unsaved second', 1, true) ~= nil)
- assert.are.same({ 'line 1', 'line 2' }, vim.fn.readfile(repo_root .. '/file.txt'))
-
- vim.api.nvim_buf_delete(source_buf, { force = true })
- commands.refresh(diff_buf)
- assert.is_nil(buffer_text(diff_buf):find('unsaved second', 1, true))
- end)
- end
-
- it('reports no unstaged changes for staged-only tracked files', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- vim.fn.writefile({ 'line 1', 'line 2 staged' }, filepath)
- git_cmd(repo_root, { 'add', 'file.txt' })
- local source_buf = edit_file(filepath)
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.is_true(
- notifications[#notifications].message:find('no changes for index -> worktree', 1, true)
- ~= nil
- )
- end)
-
- it('reports no unstaged changes for staged additions matching the worktree', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/new.txt'
- vim.fn.writefile({ 'new line' }, filepath)
- git_cmd(repo_root, { 'add', 'new.txt' })
- local source_buf = edit_file(filepath)
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.is_true(
- notifications[#notifications].message:find('no changes for index -> worktree', 1, true)
- ~= nil
- )
- end)
-
- it('reports no unstaged changes for staged deletions matching the worktree', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- git_cmd(repo_root, { 'rm', '-q', 'file.txt' })
- local source_buf = edit_file(filepath)
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.is_true(
- notifications[#notifications].message:find('no changes for index -> worktree', 1, true)
- ~= nil
- )
- end)
-
- it('shows only the unstaged edge when a file has staged and unstaged changes', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- vim.fn.writefile({ 'line 1', 'line 2 staged' }, filepath)
- git_cmd(repo_root, { 'add', 'file.txt' })
- vim.fn.writefile({ 'line 1', 'line 2 unstaged' }, filepath)
- edit_file(filepath)
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local text = buffer_text(diff_buf)
- assert.is_true(text:find('-line 2 staged', 1, true) ~= nil)
- assert.is_true(text:find('+line 2 unstaged', 1, true) ~= nil)
- assert.is_false(text:find('-line 2\n', 1, true) ~= nil)
- end)
-
- it('uses buffer EOF state for final-newline-only direct diffs', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- vim.fn.writefile({ 'line 1', 'line 2' }, filepath, 'b')
- local source_buf = edit_file(filepath)
- mock_runtime_attach(function() end)
-
- assert.is_false(vim.bo[source_buf].endofline)
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local text = buffer_text(diff_buf)
- assert.is_true(text:find('-line 2', 1, true) ~= nil)
- assert.is_true(text:find('+line 2', 1, true) ~= nil)
- assert.is_true(text:find('\\ No newline at end of file', 1, true) ~= nil)
- end)
-
- it('reports mode-only direct changes instead of rendering fake text hunks', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- vim.fn.setfperm(filepath, 'rwxr-xr-x')
- local source_buf = edit_file(filepath)
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff(nil, false)
-
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(
- notifications[#notifications].message:find(
- 'diff does not support mode-only changes',
- 1,
- true
- ) ~= nil
- )
- end)
-
- it('reports mode-only status-row changes without rendering text hunks', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
- vim.fn.setfperm(filepath, 'rwxr-xr-x')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff_file(filepath)
-
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(
- notifications[#notifications].message:find(
- 'diff does not support mode-only changes',
- 1,
- true
- ) ~= nil
- )
- end)
-
- it('reports binary status-row changes without rendering text hunks', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/bin.dat'
- write_binary_file(filepath, 'binary\\000old')
- git_cmd(repo_root, { 'add', 'bin.dat' })
- git_cmd(repo_root, { 'commit', '-qm', 'binary' })
- write_binary_file(filepath, 'binary\\000new')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff_file(filepath)
-
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(
- notifications[#notifications].message:find('diff does not support binary files', 1, true)
- ~= nil
- )
- end)
-
- it('reports submodule status-row changes without rendering pseudo text hunks', function()
- local repo_root = create_repo()
- local submodule_path = repo_root .. '/submodule'
- git_cmd(repo_root, {
- 'update-index',
- '--add',
- '--cacheinfo',
- '160000,0123456789012345678901234567890123456789,submodule',
- })
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff_file(submodule_path, { staged = true })
-
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(
- notifications[#notifications].message:find(
- 'diff does not support submodule changes',
- 1,
- true
- ) ~= nil
- )
- end)
-
- it('reports pure status-row renames as no text changes with actions unavailable', function()
- local repo_root = create_repo()
- local old_filepath = repo_root .. '/file.txt'
- local new_filepath = repo_root .. '/renamed.txt'
- git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' })
- local source_buf = edit_file(new_filepath)
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- commands.diff_file(new_filepath, { staged = true, old_filepath = old_filepath })
-
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level)
- assert.is_true(
- notifications[#notifications].message:find(
- 'no text changes for staged rename/copy file.txt -> renamed.txt',
- 1,
- true
- ) ~= nil
- )
- assert.is_true(
- notifications[#notifications].message:find(
- 'generated hunk actions are unavailable',
- 1,
- true
- ) ~= nil
- )
- end)
-
- it('renders status-row rename content changes without hunk actions', function()
- local repo_root = create_repo()
- local old_filepath = repo_root .. '/file.txt'
- local new_filepath = repo_root .. '/renamed.txt'
- git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' })
- vim.fn.writefile({ 'line 1', 'line 2 renamed' }, new_filepath)
- git_cmd(repo_root, { 'add', 'renamed.txt' })
- mock_runtime_attach(function() end)
-
- commands.diff_file(new_filepath, { staged = true, old_filepath = old_filepath })
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- local text = buffer_text(diff_buf)
- assert.are.equal('diffs://staged:renamed.txt', vim.api.nvim_buf_get_name(diff_buf))
- assert.is_true(text:find('diff --git a/file.txt b/renamed.txt', 1, true) ~= nil)
- assert.is_true(text:find('-line 2', 1, true) ~= nil)
- assert.is_true(text:find('+line 2 renamed', 1, true) ~= nil)
- assert.is_false(has_buf_var(diff_buf, 'diffs_hunks'))
- assert.is_false(helpers.has_keymap(diff_buf, 'do'))
- assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
- assert.are.same({
- version = 1,
- kind = 'file_pair',
- repo_root = repo_root,
- edge = 'staged',
- path = 'renamed.txt',
- old_path = 'file.txt',
- }, vim.api.nvim_buf_get_var(diff_buf, 'diffs_source'))
-
- local qf = quickfix_items()
- assert.are.equal(0, #qf)
-
- local loc = loclist_items()
- assert.are.equal(1, #loc)
- assert.are.equal(diff_buf, loc[1].bufnr)
- assert.is_true(loc[1].lnum > 1)
- assert.is_true(loc[1].text:find('renamed.txt', 1, true) ~= nil)
- end)
-
- it('opens staged and unstaged section headers as explicit section buffers', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2 staged' }, repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', 'file.txt' })
- vim.fn.writefile({ 'line 1', 'line 2 staged', 'line 3 unstaged' }, repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- commands.diff_section(repo_root, { staged = true })
- local staged_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, staged_buf)
- assert.are.equal('diffs://staged:all', vim.api.nvim_buf_get_name(staged_buf))
- assert.is_true(buffer_text(staged_buf):find('+line 2 staged', 1, true) ~= nil)
- assert.is_false(has_buf_var(staged_buf, 'diffs_hunks'))
- assert.are.same({
- version = 1,
- kind = 'section',
- repo_root = repo_root,
- section = 'staged',
- }, vim.api.nvim_buf_get_var(staged_buf, 'diffs_source'))
-
- commands.diff_section(repo_root)
- local unstaged_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, unstaged_buf)
- assert.are.equal('diffs://unstaged:all', vim.api.nvim_buf_get_name(unstaged_buf))
- assert.is_true(buffer_text(unstaged_buf):find('+line 3 unstaged', 1, true) ~= nil)
- assert.is_false(has_buf_var(unstaged_buf, 'diffs_hunks'))
- assert.are.same({
- version = 1,
- kind = 'section',
- repo_root = repo_root,
- section = 'unstaged',
- }, vim.api.nvim_buf_get_var(unstaged_buf, 'diffs_source'))
- end)
-
- it('pins a/ b/ for staged section diffs under diff.noprefix', function()
- local repo_root = create_repo()
- git_cmd(repo_root, { 'config', 'diff.noprefix', 'true' })
- vim.fn.writefile({ 'line 1', 'line 2 staged' }, repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', 'file.txt' })
- mock_runtime_attach(function() end)
-
- commands.diff_section(repo_root, { staged = true })
- local staged_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, staged_buf)
- assert.is_true(
- buffer_text(staged_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil
- )
- end)
-
- it('pins a/ b/ for unstaged section diffs under diff.mnemonicPrefix', function()
- local repo_root = create_repo()
- git_cmd(repo_root, { 'config', 'diff.mnemonicPrefix', 'true' })
- vim.fn.writefile({ 'line 1', 'line 2 unstaged' }, repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- commands.diff_section(repo_root)
- local unstaged_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, unstaged_buf)
- assert.is_true(
- buffer_text(unstaged_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil
- )
- end)
-
- it('renders an explicit-path object against that path worktree counterpart', function()
- local repo_root = create_repo()
- write_repo_file(repo_root, 'other.lua', { 'local a = 1', 'return a' })
- git_cmd(repo_root, { 'add', 'other.lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'add other' })
- vim.fn.writefile({ 'local a = 1', 'local b = 2', 'return a' }, repo_root .. '/other.lua')
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- commands.diff_command('HEAD:other.lua', false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- assert.are.equal('diffs://HEAD:other.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.rev_to_worktree('HEAD', 'other.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local text = table.concat(buffer_lines(diff_buf), '\n')
- assert.is_true(text:find('+local b = 2', 1, true) ~= nil)
- end)
-
- it('renders a read-only merge-stage object against the worktree', function()
- local _, filepath = create_conflicted_repo()
- edit_file(filepath)
- mock_runtime_attach(function() end)
- assert.is_true(git.is_unmerged(filepath))
-
- commands.diff_command(':2:%', false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
- assert.are.equal('diffs://stage2:file.txt', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.stage_to_worktree(2, 'file.txt'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
- assert.is_false(helpers.has_keymap(diff_buf, 'do'))
- end)
-
- it('rejects a merge-stage object when the file is not in a conflict', function()
- local repo_root = create_repo()
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
- local notifications = capture_notifications()
-
- commands.diff_command(':2:%', false)
-
- local rejected = false
- for _, n in ipairs(notifications) do
- if tostring(n.message):find('is not in a merge conflict', 1, true) then
- rejected = true
- end
- end
- assert.is_true(rejected)
- end)
-
- it('opens unmerged files with conflict handling disabled', function()
- local _, filepath = create_conflicted_repo()
- edit_file(filepath)
- mock_runtime_attach(function() end)
- mock_conflict_config(false)
-
- commands.diff(nil, false)
-
- local bufnr = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, bufnr)
- assert.is_false(helpers.has_keymap(bufnr, 'go'))
- end)
-
- it('routes direct :Diff on unmerged files to the generated unmerged view', function()
- local repo_root, filepath = create_conflicted_repo()
- local source_buf = edit_file(filepath)
- local source_win = vim.api.nvim_get_current_win()
- mock_runtime_attach(function() end)
- mock_conflict_config(true)
-
- assert.is_true(git.is_unmerged(filepath))
-
- commands.diff(nil, false)
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- local function assert_unmerged_view(bufnr, expected_rail_style)
- local lines = buffer_lines(bufnr)
- local text = table.concat(lines, '\n')
-
- assert.are.equal('diffs://unmerged:file.txt', vim.api.nvim_buf_get_name(bufnr))
- assert.are.equal(repo_root, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root)
- assert.are.same({
- version = 1,
- kind = 'unmerged',
- repo_root = repo_root,
- path = 'file.txt',
- working_path = filepath,
- }, vim.api.nvim_buf_get_var(bufnr, 'diffs_source'))
- assert.are.equal(filepath, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').working_path)
- assert.is_nil(vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec)
- assert.is_false(has_buf_var(bufnr, 'diffs_hunks'))
- assert.are.equal('diff --git a/file.txt b/file.txt', lines[1])
- assert.is_true(text:find('-line 2 theirs', 1, true) ~= nil)
- assert.is_true(text:find('+line 2 ours', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) == nil)
- assert.is_true(text:find('new file mode', 1, true) == nil)
- assert.is_true(text:find('<<<<<<<', 1, true) == nil)
- assert.is_false(helpers.has_keymap(bufnr, 'go'))
- assert.is_false(helpers.has_keymap(bufnr, 'gt'))
- if expected_rail_style then
- assert.are.equal(expected_rail_style, vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style'))
- end
- end
-
- assert_unmerged_view(diff_buf)
-
- vim.api.nvim_set_option_value('modifiable', true, { buf = diff_buf })
- vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, { 'stale unmerged content' })
- vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
-
- commands.read_buffer(diff_buf)
-
- assert_unmerged_view(diff_buf)
- helpers.delete_buffer(diff_buf)
-
- vim.api.nvim_set_current_win(source_win)
- commands.diff('++layout=stacked', false)
-
- local stacked_unmerged_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, stacked_unmerged_buf)
- assert_unmerged_view(stacked_unmerged_buf, 'single')
-
- vim.api.nvim_set_option_value('modifiable', true, { buf = stacked_unmerged_buf })
- vim.api.nvim_buf_set_lines(stacked_unmerged_buf, 0, -1, false, { 'stale unmerged content' })
- vim.api.nvim_set_option_value('modifiable', false, { buf = stacked_unmerged_buf })
-
- commands.read_buffer(stacked_unmerged_buf)
-
- assert_unmerged_view(stacked_unmerged_buf, 'single')
- helpers.delete_buffer(stacked_unmerged_buf)
-
- for _, object in ipairs({ ':%', ':0:%' }) do
- vim.api.nvim_set_current_win(source_win)
- commands.diff(object, false)
-
- local explicit_index_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, explicit_index_buf)
- assert_unmerged_view(explicit_index_buf)
- helpers.delete_buffer(explicit_index_buf)
- end
-
- vim.api.nvim_set_current_win(source_win)
- commands.diff('HEAD', false)
-
- local revision_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, revision_buf)
- local revision_text = buffer_text(revision_buf)
-
- assert.are.equal('diffs://HEAD:file.txt', vim.api.nvim_buf_get_name(revision_buf))
- assert.are.same(
- diffspec.rev_to_worktree('HEAD', 'file.txt'),
- vim.api.nvim_buf_get_var(revision_buf, 'diffs_source').spec
- )
- assert.are.same({
- version = 1,
- kind = 'file',
- repo_root = repo_root,
- spec = diffspec.rev_to_worktree('HEAD', 'file.txt'),
- worktree_buf = source_buf,
- }, vim.api.nvim_buf_get_var(revision_buf, 'diffs_source'))
- assert.is_true(revision_text:find('+<<<<<<<', 1, true) ~= nil)
- end)
- end)
-
- describe('diff_file DiffSpec metadata', function()
- it('marks fugitive-style staged file diffs as HEAD to index buffers', function()
- mock_git_method('get_relative_path', function(filepath)
- if filepath == '/tmp/repo/lua/foo.lua' then
- return 'lua/foo.lua'
- end
- return nil
- end)
- mock_git_method('get_file_content', function(revision, filepath)
- assert.are.equal('HEAD', revision)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'local x = 1', 'return M' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff_file('/tmp/repo/lua/foo.lua', { staged = true })
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- assert.are.equal('diffs://staged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.head_to_index('lua/foo.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('index', diff_hunks[1].mutation_target)
- assert.is_false(diff_hunks[1].can_put)
- assert.is_true(diff_hunks[1].can_obtain)
- assert.is_true(helpers.has_keymap(diff_buf, 'do'))
- assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
- end)
-
- it('marks fugitive-style unstaged file diffs as index to worktree buffers', function()
- mock_git_method('get_relative_path', function(filepath)
- if filepath == '/tmp/repo/lua/foo.lua' then
- return 'lua/foo.lua'
- end
- return nil
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_git_method('get_working_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return { 'local M = {}', 'local x = 1', 'return M' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/foo.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff_file('/tmp/repo/lua/foo.lua')
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- assert.are.equal('diffs://unstaged:lua/foo.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.index_to_worktree('lua/foo.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('worktree', diff_hunks[1].mutation_target)
- assert.is_true(diff_hunks[1].can_put)
- assert.is_false(diff_hunks[1].can_obtain)
- assert.is_false(helpers.has_keymap(diff_buf, 'do'))
- assert.is_true(helpers.has_keymap(diff_buf, 'dp'))
- end)
-
- it('marks fugitive-style untracked file diffs as index to worktree buffers', function()
- mock_git_method('get_relative_path', function(filepath)
- if filepath == '/tmp/repo/lua/new.lua' then
- return 'lua/new.lua'
- end
- return nil
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return nil, 'file not in index'
- end)
- mock_git_method('get_working_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff_file('/tmp/repo/lua/new.lua', { untracked = true })
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- assert.are.equal('diffs://untracked:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.index_to_worktree('lua/new.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local text = buffer_text(diff_buf)
- assert.is_true(text:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) ~= nil)
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('worktree', diff_hunks[1].mutation_target)
- assert.is_true(diff_hunks[1].can_put)
- assert.is_false(diff_hunks[1].can_obtain)
- assert.is_true(diff_hunks[1].actionable)
- assert.is_false(helpers.has_keymap(diff_buf, 'do'))
- assert.is_true(helpers.has_keymap(diff_buf, 'dp'))
- end)
-
- it('marks fugitive-style staged additions as HEAD to index buffers', function()
- mock_git_method('get_relative_path', function(filepath)
- if filepath == '/tmp/repo/lua/new.lua' then
- return 'lua/new.lua'
- end
- return nil
- end)
- mock_git_method('get_file_content', function(revision, filepath)
- assert.are.equal('HEAD', revision)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return nil, 'file not in revision: HEAD'
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/new.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff_file('/tmp/repo/lua/new.lua', { staged = true })
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- assert.are.equal('diffs://staged:lua/new.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.head_to_index('lua/new.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local text = buffer_text(diff_buf)
- assert.is_true(text:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) ~= nil)
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('index', diff_hunks[1].mutation_target)
- assert.is_false(diff_hunks[1].can_put)
- assert.is_true(diff_hunks[1].can_obtain)
- assert.is_true(diff_hunks[1].actionable)
- assert.is_true(helpers.has_keymap(diff_buf, 'do'))
- assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
- end)
-
- it('marks fugitive-style staged deletions as HEAD to index buffers', function()
- mock_git_method('get_relative_path', function(filepath)
- if filepath == '/tmp/repo/lua/deleted.lua' then
- return 'lua/deleted.lua'
- end
- return nil
- end)
- mock_git_method('get_file_content', function(revision, filepath)
- assert.are.equal('HEAD', revision)
- assert.are.equal('/tmp/repo/lua/deleted.lua', filepath)
- return { 'local M = {}', 'return M' }
- end)
- mock_git_method('get_index_content', function(filepath)
- assert.are.equal('/tmp/repo/lua/deleted.lua', filepath)
- return nil, 'file not in index'
- end)
- mock_repo_root(function(filepath)
- assert.are.equal('/tmp/repo/lua/deleted.lua', filepath)
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
-
- commands.diff_file('/tmp/repo/lua/deleted.lua', { staged = true })
-
- local diff_buf = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, diff_buf)
-
- assert.are.equal('diffs://staged:lua/deleted.lua', vim.api.nvim_buf_get_name(diff_buf))
- assert.are.same(
- diffspec.head_to_index('lua/deleted.lua'),
- vim.api.nvim_buf_get_var(diff_buf, 'diffs_source').spec
- )
- local text = buffer_text(diff_buf)
- assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil)
- local diff_hunks = vim.api.nvim_buf_get_var(diff_buf, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('index', diff_hunks[1].mutation_target)
- assert.is_false(diff_hunks[1].can_put)
- assert.is_true(diff_hunks[1].can_obtain)
- assert.is_true(diff_hunks[1].actionable)
- assert.is_true(helpers.has_keymap(diff_buf, 'do'))
- assert.is_false(helpers.has_keymap(diff_buf, 'dp'))
- end)
- end)
-
- describe('generated buffer lifecycle matrix', function()
- local section_lines = {
- 'diff --git a/lua/section.lua b/lua/section.lua',
- '--- a/lua/section.lua',
- '+++ b/lua/section.lua',
- '@@ -1 +1 @@',
- '-old section',
- '+new section',
- }
-
- local review_lines = {
- 'diff --git a/lua/review.lua b/lua/review.lua',
- '--- a/lua/review.lua',
- '+++ b/lua/review.lua',
- '@@ -1 +1 @@',
- '-old review',
- '+new review',
- }
-
- local file_content = {
- ['/tmp/repo/lua/unstaged.lua'] = {
- index = { 'local M = {}', 'return M' },
- worktree = { 'local M = {}', 'local unstaged = true', 'return M' },
- },
- ['/tmp/repo/lua/staged.lua'] = {
- head = { 'local M = {}', 'return M' },
- index = { 'local M = {}', 'local staged = true', 'return M' },
- },
- ['/tmp/repo/lua/new.lua'] = {
- worktree = { 'local M = {}', 'local new_file = true', 'return M' },
- },
- }
-
- local function contains_cmd_arg(cmd, arg)
- for _, value in ipairs(cmd) do
- if value == arg then
- return true
- end
- end
- return false
- end
-
- local function install_lifecycle_mocks()
- mock_git_method('get_relative_path', function(filepath)
- return filepath:match('^/tmp/repo/(.+)$')
- end)
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_git_method('get_file_content', function(revision, filepath)
- local entry = file_content[filepath]
- if not entry or not entry.head then
- return nil, 'file not in revision: ' .. revision
- end
- return entry.head
- end)
- mock_git_method('get_index_content', function(filepath)
- local entry = file_content[filepath]
- if not entry or not entry.index then
- return nil, 'file not in index'
- end
- return entry.index
- end)
- mock_git_method('get_working_content', function(filepath)
- local entry = file_content[filepath]
- if not entry or not entry.worktree then
- return nil, 'file not readable'
- end
- return entry.worktree
- end)
- mock_git_method('get_tree_mode', function()
- return '100644'
- end)
- mock_git_method('get_index_mode', function(filepath)
- local entry = file_content[filepath]
- return entry and entry.index and '100644' or nil
- end)
- mock_git_method('get_working_mode', function(filepath)
- local entry = file_content[filepath]
- return entry and entry.worktree and '100644' or nil
- end)
- mock_systemlist(function(cmd)
- if cmd[4] == 'rev-parse' then
- return { 'commit' }
- end
- if cmd[4] == 'merge-base' then
- return { 'merge-base-commit' }
- end
- if
- contains_cmd_arg(cmd, '--name-status')
- or contains_cmd_arg(cmd, '--numstat')
- or contains_cmd_arg(cmd, '--raw')
- then
- return {}
- end
- if contains_cmd_arg(cmd, 'origin/main...refs/forge/pr/42') then
- return review_lines
- end
- return section_lines
- end)
- mock_runtime_attach(function() end)
- end
-
- local function set_stale_content(bufnr)
- vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale lifecycle content' })
- vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
- end
-
- local function assert_generated_options(bufnr)
- assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
- assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr }))
- assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr }))
- assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr }))
- assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
- end
-
- local function assert_no_hunk_maps(bufnr)
- assert.is_false(helpers.has_keymap(bufnr, ']c'))
- assert.is_false(helpers.has_keymap(bufnr, '[c'))
- assert.is_false(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, 'o'))
- assert.is_false(helpers.has_keymap(bufnr, 'do'))
- assert.is_false(helpers.has_keymap(bufnr, 'dp'))
- assert.is_false(helpers.has_keymap(bufnr, 'do', 'x'))
- assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x'))
- end
-
- local function assert_hunk_maps(bufnr, hunk)
- assert.is_true(helpers.has_keymap(bufnr, ']c'))
- assert.is_true(helpers.has_keymap(bufnr, '[c'))
- assert.is_true(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, 'o'))
- assert.are.equal(hunk.can_obtain, helpers.has_keymap(bufnr, 'do'))
- assert.are.equal(hunk.can_put, helpers.has_keymap(bufnr, 'dp'))
- assert.are.equal(hunk.can_obtain, helpers.has_keymap(bufnr, 'do', 'x'))
- assert.are.equal(hunk.can_put, helpers.has_keymap(bufnr, 'dp', 'x'))
- end
-
- local function assert_lifecycle_state(bufnr, case)
- assert.are.equal(case.buffer_name, vim.api.nvim_buf_get_name(bufnr))
- assert.are.equal('/tmp/repo', vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root)
- assert_generated_options(bufnr)
-
- if case.diff_spec then
- assert.are.same(case.diff_spec, vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec)
- else
- assert.is_nil(vim.api.nvim_buf_get_var(bufnr, 'diffs_source').spec)
- end
-
- assert.are.same(case.source, vim.api.nvim_buf_get_var(bufnr, 'diffs_source'))
-
- if case.hunk then
- local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal(case.hunk.file, diff_hunks[1].file)
- assert.are.equal(case.hunk.mutation_target, diff_hunks[1].mutation_target)
- assert.are.equal(case.hunk.can_put, diff_hunks[1].can_put)
- assert.are.equal(case.hunk.can_obtain, diff_hunks[1].can_obtain)
- assert.are.equal(case.hunk.can_put or case.hunk.can_obtain, diff_hunks[1].actionable)
- assert_hunk_maps(bufnr, case.hunk)
- else
- assert.is_false(has_buf_var(bufnr, 'diffs_hunks'))
- assert_no_hunk_maps(bufnr)
- end
-
- local lines = buffer_lines(bufnr)
- assert.are.equal(case.first_line, lines[1])
- assert.is_false(vim.tbl_contains(lines, 'stale lifecycle content'))
-
- local qf = quickfix_items()
- if case.review then
- assert.is_true(#qf >= 1, case.label)
- assert.are.equal(bufnr, qf[1].bufnr, case.label)
- assert.are.equal(1, qf[1].lnum, case.label)
- else
- assert.are.equal(0, #qf, case.label)
- end
-
- local loc = loclist_items()
- assert.is_true(#loc >= 1, case.label)
- assert.are.equal(bufnr, loc[1].bufnr, case.label)
- assert.is_true(loc[1].lnum > 1, case.label)
- end
-
- it('preserves generated buffer metadata and action keymaps across reloads', function()
- install_lifecycle_mocks()
-
- local cases = {
- {
- label = 'unstaged file',
- open = function()
- commands.diff_file('/tmp/repo/lua/unstaged.lua')
- end,
- buffer_name = 'diffs://unstaged:lua/unstaged.lua',
- diff_spec = diffspec.index_to_worktree('lua/unstaged.lua'),
- source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp/repo',
- spec = diffspec.index_to_worktree('lua/unstaged.lua'),
- },
- hunk = {
- file = 'lua/unstaged.lua',
- mutation_target = 'worktree',
- can_put = true,
- can_obtain = false,
- },
- first_line = 'diff --git a/lua/unstaged.lua b/lua/unstaged.lua',
- },
- {
- label = 'staged file',
- open = function()
- commands.diff_file('/tmp/repo/lua/staged.lua', { staged = true })
- end,
- buffer_name = 'diffs://staged:lua/staged.lua',
- diff_spec = diffspec.head_to_index('lua/staged.lua'),
- source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp/repo',
- spec = diffspec.head_to_index('lua/staged.lua'),
- },
- hunk = {
- file = 'lua/staged.lua',
- mutation_target = 'index',
- can_put = false,
- can_obtain = true,
- },
- first_line = 'diff --git a/lua/staged.lua b/lua/staged.lua',
- },
- {
- label = 'untracked file',
- open = function()
- commands.diff_file('/tmp/repo/lua/new.lua', { untracked = true })
- end,
- buffer_name = 'diffs://untracked:lua/new.lua',
- diff_spec = diffspec.index_to_worktree('lua/new.lua'),
- source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp/repo',
- spec = diffspec.index_to_worktree('lua/new.lua'),
- },
- hunk = {
- file = 'lua/new.lua',
- mutation_target = 'worktree',
- can_put = true,
- can_obtain = false,
- },
- first_line = 'diff --git a/lua/new.lua b/lua/new.lua',
- },
- {
- label = 'unstaged section',
- open = function()
- commands.diff_section('/tmp/repo')
- end,
- buffer_name = 'diffs://unstaged:all',
- source = {
- version = 1,
- kind = 'section',
- repo_root = '/tmp/repo',
- section = 'unstaged',
- },
- first_line = 'diff --git a/lua/section.lua b/lua/section.lua',
- },
- {
- label = 'review',
- open = function()
- commands.review({
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- mode = 'merge-base',
- repo = '/tmp/repo',
- })
- end,
- buffer_name = 'diffs://review:origin/main...refs/forge/pr/42',
- source = {
- version = 1,
- kind = 'review',
- repo_root = '/tmp/repo',
- review = {
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- mode = 'merge-base',
- untracked = true,
- },
- },
- review = {
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- mode = 'merge-base',
- },
- first_line = 'diff --git a/lua/review.lua b/lua/review.lua',
- },
- }
-
- for _, case in ipairs(cases) do
- case.open()
- local bufnr = vim.api.nvim_get_current_buf()
- table.insert(test_buffers, bufnr)
-
- assert_lifecycle_state(bufnr, case)
- set_stale_content(bufnr)
-
- assert.has_no.errors(function()
- commands.read_buffer(bufnr)
- end, case.label)
- assert_lifecycle_state(bufnr, case)
- end
- end)
- end)
-
- describe('review helpers', function()
- it('normalizes default base inside the resolved repo', function()
- local captured_cmds = {}
- mock_repo_root(function(path)
- assert.are.equal('/tmp/repo/.', path)
- return '/tmp/repo'
- end)
- mock_systemlist(function(cmd)
- captured_cmds[#captured_cmds + 1] = cmd
- if cmd[4] == 'symbolic-ref' then
- return { 'refs/remotes/origin/main' }
- end
- if cmd[4] == 'rev-parse' then
- return { 'commit' }
- end
- return {}
- end)
-
- local review = commands._test.normalize_review({ repo = '/tmp/repo' })
-
- assert.are.equal('/tmp/repo', review.repo_root)
- assert.are.equal('origin/main', review.base)
- assert.are.equal('origin/main', review.display)
- assert.are.same(
- { 'git', '-C', '/tmp/repo', 'symbolic-ref', 'refs/remotes/origin/HEAD' },
- captured_cmds[1]
- )
- assert.are.equal(1, #captured_cmds)
- end)
-
- it('completes target refs after merge-base separator', function()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_systemlist(function(cmd)
- assert.are.same({
- 'git',
- '-C',
- '/tmp/repo',
- 'for-each-ref',
- '--format=%(refname:short)',
- 'refs/heads/',
- 'refs/remotes/',
- 'refs/tags/',
- }, cmd)
- return { 'origin/main', 'refs/forge/pr/42', 'refs/forge/pr/43' }
- end)
-
- local matches = commands._test.complete_review('origin/main...refs/forge/pr/4')
-
- assert.are.same({
- 'origin/main...refs/forge/pr/42',
- 'origin/main...refs/forge/pr/43',
- }, matches)
- end)
-
- it('completes target refs after direct separator', function()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_systemlist(function()
- return { 'origin/main', 'feature/a', 'feature/b' }
- end)
-
- local matches = commands._test.complete_review('origin/main..feature/')
-
- assert.are.same({ 'origin/main..feature/a', 'origin/main..feature/b' }, matches)
- end)
-
- it('completes :Diff review layout options only before a review spec', function()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_systemlist(function()
- return { 'origin/main', 'feature/topic', '++topic' }
- end)
-
- assert.are.same({
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
- '++nountracked',
- 'origin/main',
- 'feature/topic',
- '++topic',
- }, commands._test.complete_diff('', 'Diff review ', #'Diff review '))
- assert.are.same(
- {
- '++nountracked',
- 'origin/main',
- 'feature/topic',
- '++topic',
- },
- commands._test.complete_diff(
- '',
- 'Diff review ++layout=split ',
- #'Diff review ++layout=split '
- )
- )
- assert.are.same(
- { 'origin/main', 'feature/topic', '++topic' },
- commands._test.complete_diff('', 'Diff review origin/main ', #'Diff review origin/main ')
- )
- assert.are.same(
- { 'origin/main' },
- commands._test.complete_diff('origin/', 'Diff review origin/', #'Diff review origin/')
- )
- assert.are.same(
- { 'origin/main' },
- commands._test.complete_diff(
- 'origin/',
- 'belowright Diff review origin/',
- #'belowright Diff review origin/'
- )
- )
- assert.are.same({
- '++layout=unified',
- '++layout=stacked',
- '++layout=split',
- '++nountracked',
- '++topic',
- }, commands._test.complete_diff('++', 'Diff review ++', #'Diff review ++'))
- assert.are.same(
- { '++nountracked', '++topic' },
- commands._test.complete_diff(
- '++',
- 'Diff review ++layout=split ++',
- #'Diff review ++layout=split ++'
- )
- )
- end)
- end)
-
- describe('review', function()
- it('reports Git errors from the operation without preflight ref checks', function()
- for _, case in ipairs({
- {
- base = 'missing/ref',
- operation = 'merge-base',
- message = 'fatal: Not a valid object name missing/ref',
- },
- {
- base = 'HEAD',
- target = 'missing/target',
- operation = 'diff',
- message = "fatal: bad revision 'missing/target'",
- },
- {
- base = 'HEAD',
- target = 'other',
- operation = 'diff',
- message = 'fatal: no merge base found',
- },
- }) do
- local called = {}
- local notifications = capture_notifications()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_systemlist(function(cmd)
- called[#called + 1] = cmd[4]
- return { case.message }, 128
- end)
-
- local bufnr = commands.review({
- base = case.base,
- target = case.target,
- repo = '/tmp/repo',
- })
-
- assert.is_nil(bufnr)
- assert.are.same({ case.operation }, called)
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(notifications[#notifications].message:find(case.message, 1, true) ~= nil)
- restore_mocks()
- end
- end)
-
- it(
- 'passes invalid revision arguments to Git without treating them as paths or flags',
- function()
- local repo_root = create_repo()
- local notifications = capture_notifications()
- for _, target in ipairs({ 'file.txt', '--stat' }) do
- local bufnr = commands.review({
- base = 'HEAD',
- target = target,
- mode = 'direct',
- repo = repo_root,
- })
- assert.is_nil(bufnr)
- assert.are.equal(vim.log.levels.ERROR, notifications[#notifications].level)
- assert.is_true(notifications[#notifications].message:find(target, 1, true) ~= nil)
- end
- end
- )
-
- it('reports successful Git warnings without inserting them into review buffers', function()
- mock_repo_root(function()
- return '/tmp/repo'
- end)
- mock_runtime_attach(function() end)
- local notifications = capture_notifications()
- mock_systemlist(function()
- return {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- },
- 0,
- { 'warning: multiple merge bases' }
- end)
- local bufnr = commands.review({ base = 'main', target = 'topic', repo = '/tmp/repo' })
- assert.is_not_nil(bufnr)
- test_buffers[#test_buffers + 1] = bufnr
- assert.is_nil(buffer_text(bufnr):find('warning:', 1, true))
- assert.is_true(buffer_text(bufnr):find('+new', 1, true) ~= nil)
- assert.are.equal(vim.log.levels.WARN, notifications[1].level)
- assert.is_true(notifications[1].message:find('warning: multiple merge bases', 1, true) ~= nil)
- end)
-
- it('opens a review buffer for an explicit merge-base target', function()
- local captured_cmd
- mock_repo_root(function(path)
- assert.are.equal('/tmp/repo/.', path)
- return '/tmp/repo'
- end)
- mock_systemlist(function(cmd)
- if cmd[4] == 'rev-parse' then
- return { 'commit' }
- end
- if cmd[4] == 'merge-base' then
- return { 'merge-base-commit' }
- end
- if cmd[4] ~= 'diff' then
- return {}
- end
- captured_cmd = cmd
- return {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- }
- end)
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- mode = 'merge-base',
- repo = '/tmp/repo',
- })
- table.insert(test_buffers, bufnr)
-
- assert.are.same({
- 'git',
- '-C',
- '/tmp/repo',
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- '--end-of-options',
- 'origin/main...refs/forge/pr/42',
- '--',
- }, captured_cmd)
- assert.are.equal(
- 'diffs://review:origin/main...refs/forge/pr/42',
- vim.api.nvim_buf_get_name(bufnr)
- )
- end)
-
- it('initializes generated review metadata before diff FileType attach', function()
- local attach_records = {}
- saved_schedule = vim.schedule
- vim.schedule = function(callback)
- callback()
- end
-
- local group = vim.api.nvim_create_augroup('DiffsGeneratedReviewFileTypeRegression', {
- clear = true,
- })
- vim.api.nvim_create_autocmd('FileType', {
- pattern = 'diff',
- group = group,
- callback = function(args)
- runtime.attach(args.buf)
- local rail_ok, rail_width = pcall(vim.api.nvim_buf_get_var, args.buf, 'diffs_rail_width')
- local entry = runtime._test.hunk_cache[args.buf]
- attach_records[#attach_records + 1] = {
- name = vim.api.nvim_buf_get_name(args.buf),
- rail_width = rail_ok and rail_width or nil,
- hunk_count = entry and #entry.hunks or nil,
- }
- end,
- })
-
- local repo = create_review_repo({ named_refs = true })
- local ok, bufnr = pcall(function()
- return commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'merge-base',
- repo = repo.repo_root,
- })
- end)
- pcall(vim.api.nvim_del_augroup_by_id, group)
-
- assert.is_true(ok)
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local first_attach = attach_records[1]
- assert.is_not_nil(first_attach)
- assert.are.equal('diffs://review:review-base...review-topic', first_attach.name)
- assert.is_number(first_attach.rail_width)
- assert.is_true(first_attach.rail_width > 0)
- assert.is_number(first_attach.hunk_count)
- assert.is_true(first_attach.hunk_count > 0)
- end)
-
- it('renders current-state reviews as stacked sections', function()
- local repo = create_current_state_review_repo()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_true(text:find('# Branch:', 1, true) ~= nil)
- assert.is_true(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_true(text:find('# Untracked:', 1, true) ~= nil)
- assert.is_true(text:find('+branch head', 1, true) ~= nil)
- assert.is_true(text:find('+dup staged', 1, true) ~= nil)
- assert.is_true(text:find('+dup unstaged', 1, true) ~= nil)
- assert.is_true(text:find('+new file', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(7, #qf)
- assert.are.equal('lua/branch.lua', vim.trim(qf[1].module))
- assert.are.equal('lua/dup.lua', vim.trim(qf[2].module))
- assert.are.equal('lua/dup.lua', vim.trim(qf[3].module))
- assert.are.equal('lua/staged.lua', vim.trim(qf[4].module))
- assert.are.equal('lua/dup.lua', vim.trim(qf[5].module))
- assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module))
- assert.are.equal('lua/new.lua', vim.trim(qf[7].module))
-
- assert.are.equal('branch:lua/dup.lua', qf[2].user_data.diffs.key)
- assert.are.equal('staged:lua/dup.lua', qf[3].user_data.diffs.key)
- assert.are.equal('unstaged:lua/dup.lua', qf[5].user_data.diffs.key)
- end)
-
- it('omits the untracked section when spec.untracked is false', function()
- local repo = create_current_state_review_repo()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- untracked = false,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_true(text:find('# Branch:', 1, true) ~= nil)
- assert.is_true(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_nil(text:find('# Untracked:', 1, true))
- assert.is_nil(text:find('+new file', 1, true))
-
- local qf = quickfix_items()
- assert.are.equal(6, #qf)
- for _, item in ipairs(qf) do
- assert.is_nil(item.text:find('[Untracked]', 1, true))
- end
- end)
-
- it('preserves untracked=false across reload', function()
- local repo = create_current_state_review_repo()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- untracked = false,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- commands.read_buffer(bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_nil(text:find('# Untracked:', 1, true))
- end)
-
- it('renders worktree sections when diff.mnemonicPrefix is enabled', function()
- local repo = create_current_state_review_repo({ mnemonic_prefix = true })
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_true(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_true(text:find('+staged changed', 1, true) ~= nil)
- assert.is_true(text:find('+unstaged changed', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(7, #qf)
- assert.are.equal('lua/dup.lua', vim.trim(qf[3].module))
- assert.are.equal('lua/staged.lua', vim.trim(qf[4].module))
- assert.are.equal('lua/dup.lua', vim.trim(qf[5].module))
- assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module))
- end)
-
- it('renders worktree sections when diff.noprefix is enabled', function()
- local repo = create_current_state_review_repo({ noprefix = true })
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_true(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_true(text:find('+staged changed', 1, true) ~= nil)
- assert.is_true(text:find('+unstaged changed', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(7, #qf)
- assert.are.equal('lua/dup.lua', vim.trim(qf[3].module))
- assert.are.equal('lua/staged.lua', vim.trim(qf[4].module))
- assert.are.equal('lua/dup.lua', vim.trim(qf[5].module))
- assert.are.equal('lua/unstaged.lua', vim.trim(qf[6].module))
- end)
-
- it('opens review stacked layout as a single generated review map with single rails', function()
- local repo = create_current_state_review_repo()
- edit_file(repo.repo_root .. '/lua/branch.lua')
- mock_runtime_attach(function() end)
- mock_view_config({ prefix = true })
-
- local bufnr = commands.review_command(('++layout=stacked %s'):format(repo.base))
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local display_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- local text = buffer_text(bufnr)
-
- assert.are.equal('diffs://review:' .. repo.base, vim.api.nvim_buf_get_name(bufnr))
- assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style'))
- assert.are.equal('single', rails.style_for_buffer(bufnr))
- assert.are.same({ display = repo.base, layout = 'stacked' }, vim.b[bufnr].diffs_review)
- assert.is_true(helpers.has_keymap(bufnr, 'gs'))
- assert.is_true(display_lines[1]:find(' │ # Branch:', 1, true) ~= nil)
- assert.is_true(text:find('# Branch:', 1, true) ~= nil)
- assert.is_true(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_true(text:find('# Untracked:', 1, true) ~= nil)
- assert.is_true(text:find('+branch head', 1, true) ~= nil)
- assert.is_nil(commands._test.review_split_state(bufnr))
-
- local qf = quickfix_items()
- assert.are.equal(7, #qf)
- assert.are.equal(bufnr, qf[1].bufnr)
- assert.are.equal('branch:lua/branch.lua', qf[1].user_data.diffs.key)
- assert.are.equal('staged:lua/dup.lua', qf[3].user_data.diffs.key)
- assert.are.equal('unstaged:lua/dup.lua', qf[5].user_data.diffs.key)
-
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win))
- assert.is_false(name:match('^diffs://review%-split:') ~= nil)
- assert.is_false(name:match('^diffs://review%-target:') ~= nil)
- end
- end)
-
- local function main_windows()
- local wins = {}
- for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
- local buf = vim.api.nvim_win_get_buf(win)
- if vim.api.nvim_get_option_value('buftype', { buf = buf }) ~= 'quickfix' then
- wins[#wins + 1] = win
- end
- end
- return wins
- end
-
- local function visible_review_map()
- for _, win in ipairs(main_windows()) do
- local name = vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win))
- if name:match('^diffs://review:') then
- return win
- end
- end
- return nil
- end
-
- local function track_panes(left_buf)
- assert.is_not_nil(left_buf)
- table.insert(test_buffers, left_buf)
- local right_buf = vim.api.nvim_buf_get_var(left_buf, 'diffs_split_peer')
- table.insert(test_buffers, right_buf)
- local state = commands._test.review_split_state(left_buf)
- assert.is_not_nil(state)
- assert.are.equal(left_buf, state.left_buf)
- assert.are.equal(right_buf, state.right_buf)
- return {
- state = state,
- left_buf = left_buf,
- right_buf = right_buf,
- left_win = state.left_win,
- right_win = state.right_win,
- }
- end
-
- local function run_buf_keymap(bufnr, lhs)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, 'n')) do
- if keymap.lhs == lhs and keymap.callback then
- keymap.callback()
- return
- end
- end
- error('missing buffer keymap: ' .. lhs)
- end
-
- local function assert_target_at_hunk(panes, hunk_index)
- local hunk = loclist_items(panes.right_win)[hunk_index]
- assert.is_not_nil(hunk)
- local left_row = vim.api.nvim_win_get_cursor(panes.left_win)[1]
- local right_row = vim.api.nvim_win_get_cursor(panes.right_win)[1]
- assert.are.equal(left_row, right_row)
- assert.is_true(right_row >= 1)
- end
-
- local function create_binary_first_review_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
-
- write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000old')
- write_repo_file(repo_root, 'zzz-changed.lua', { 'old' })
- git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'base' })
- git_cmd(repo_root, { 'branch', 'binary-first-base' })
-
- git_cmd(repo_root, { 'checkout', '-qb', 'binary-first-topic' })
- write_binary_file(repo_root .. '/aaa-bin.dat', 'binary\\000new')
- write_repo_file(repo_root, 'zzz-changed.lua', { 'new' })
- git_cmd(repo_root, { 'add', 'aaa-bin.dat', 'zzz-changed.lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'binary and content' })
-
- return repo_root
- end
-
- local function create_binary_review_repo(opts)
- opts = opts or {}
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
-
- write_repo_file(repo_root, 'aaa-one.lua', { 'old one' })
- write_binary_file(repo_root .. '/bbb-bin.dat', 'binary\\000old')
- if opts.trailing ~= false then
- write_repo_file(repo_root, 'ccc-two.lua', { 'old two' })
- git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat', 'ccc-two.lua' })
- else
- git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat' })
- end
- git_cmd(repo_root, { 'commit', '-qm', 'base' })
- git_cmd(repo_root, { 'branch', 'binary-base' })
-
- git_cmd(repo_root, { 'checkout', '-qb', 'binary-topic' })
- write_repo_file(repo_root, 'aaa-one.lua', { 'new one' })
- write_binary_file(repo_root .. '/bbb-bin.dat', 'binary\\000new')
- if opts.trailing ~= false then
- write_repo_file(repo_root, 'ccc-two.lua', { 'new two' })
- git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat', 'ccc-two.lua' })
- else
- git_cmd(repo_root, { 'add', 'aaa-one.lua', 'bbb-bin.dat' })
- end
- git_cmd(repo_root, { 'commit', '-qm', 'target' })
-
- return repo_root
- end
-
- it('opens review layout split as exactly two visible surfaces', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt')
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split HEAD')
- local panes = track_panes(left_buf)
-
- assert.are.equal(
- 'diffs://split:left:index:file.txt',
- vim.api.nvim_buf_get_name(panes.left_buf)
- )
- assert.are.equal(
- 'diffs://split:right:worktree:file.txt',
- vim.api.nvim_buf_get_name(panes.right_buf)
- )
- assert.are.same(
- diffspec.index_to_worktree('file.txt'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.index_to_worktree('file.txt'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- assert.are.equal('left', vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').side)
- assert.are.equal('right', vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').side)
- assert.are.same(
- { 'line 1', 'line 2' },
- vim.api.nvim_buf_get_lines(panes.left_buf, 0, -1, false)
- )
- assert.are.same(
- { 'line 1', 'line 2 changed' },
- vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false)
- )
- assert.are.equal(2, #main_windows())
- assert.is_nil(visible_review_map())
- assert.is_true(helpers.has_keymap(panes.left_buf, 'gs'))
- assert.is_true(helpers.has_keymap(panes.right_buf, 'gs'))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = panes.left_win }))
- assert.is_false(vim.api.nvim_get_option_value('diff', { win = panes.right_win }))
- assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = panes.left_win }))
- assert.is_true(vim.api.nvim_get_option_value('scrollbind', { win = panes.right_win }))
-
- assert.are.equal(0, #quickfix_items())
-
- local loc = loclist_items(panes.left_win)
- assert.are.equal(1, #loc)
- assert.are.equal(panes.left_buf, loc[1].bufnr)
- assert.is_true(loc[1].text:find('file.txt', 1, true) ~= nil)
- end)
-
- it('excludes untracked files from the split file list with ++nountracked', function()
- local repo = create_current_state_review_repo()
- edit_file(repo.repo_root .. '/lua/branch.lua')
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split ++nountracked ' .. repo.base)
- assert.is_not_nil(left_buf)
- local panes = track_panes(left_buf)
-
- local files = commands.review_files(panes.left_buf)
- local paths = {}
- for _, file in ipairs(files) do
- paths[file.path] = true
- end
- assert.is_nil(paths['lua/new.lua'])
- assert.is_true(paths['lua/staged.lua'])
- assert.is_true(paths['lua/unstaged.lua'])
- end)
-
- it('warns and still opens the workspace for :vertical Diff review ++layout=split', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt')
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
- local notifications = capture_notifications()
-
- local left_buf =
- commands.review_command('++layout=split HEAD', true, { warn_vertical_split = true })
- track_panes(left_buf)
-
- local warned = false
- for _, n in ipairs(notifications) do
- if tostring(n.message):find('++layout=split ignores the :vertical modifier', 1, true) then
- warned = true
- end
- end
- assert.is_true(warned)
- assert.are.equal(2, #main_windows())
- end)
-
- it('replaces the visible review buffer even when another window is current', function()
- local repo = create_review_repo()
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'direct',
- repo = repo.repo_root,
- })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- local review_win = find_window_for_buffer(review_buf)
- assert.is_not_nil(review_win)
-
- vim.cmd('rightbelow vsplit')
- local source_buf = edit_file(repo.repo_root .. '/lua/one.lua')
- local source_win = vim.api.nvim_get_current_win()
-
- local left_buf = commands.review_split({ bufnr = review_buf })
- local panes = track_panes(left_buf)
-
- assert.are.equal(review_win, panes.left_win)
- assert.are.equal(source_buf, vim.api.nvim_win_get_buf(source_win))
- assert.is_nil(find_window_for_buffer(review_buf))
- assert.is_not_nil(find_window_for_buffer(panes.left_buf))
- assert.is_not_nil(find_window_for_buffer(panes.right_buf))
- end)
-
- it('warns instead of splitting when the selected review buffer is hidden', function()
- local repo = create_review_repo()
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'direct',
- repo = repo.repo_root,
- })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- vim.api.nvim_set_option_value('bufhidden', 'hide', { buf = review_buf })
-
- local source_buf = edit_file(repo.repo_root .. '/lua/one.lua')
- local source_win = vim.api.nvim_get_current_win()
-
- local opened = commands.review_split({ bufnr = review_buf })
-
- assert.is_nil(opened)
- assert.are.equal(source_buf, vim.api.nvim_win_get_buf(source_win))
- assert.is_true(
- notifications[#notifications].message:find('selected review buffer is not visible', 1, true)
- ~= nil
- )
- end)
-
- it('toggles a selected stacked review file to split and back to the review map', function()
- local repo = create_review_repo()
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'direct',
- repo = repo.repo_root,
- }, {
- rail_style = 'single',
- review_layout = 'stacked',
- })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- local review_win = find_window_for_buffer(review_buf)
- assert.is_not_nil(review_win)
- local line = find_buffer_line(review_buf, 'diff --git a/lua/two.lua b/lua/two.lua')
- assert.is_not_nil(line)
- vim.api.nvim_set_current_win(review_win)
- vim.api.nvim_win_set_cursor(review_win, { line, 0 })
-
- local left_buf = commands.review_toggle_layout()
- local panes = track_panes(left_buf)
-
- assert.are.equal('lua/two.lua', panes.state.selected_file)
- assert.are.equal('stacked', panes.state.map_layout)
- assert.is_nil(visible_review_map())
- assert.is_true(helpers.has_keymap(panes.left_buf, 'gs'))
- assert.is_true(helpers.has_keymap(panes.right_buf, 'gs'))
-
- vim.api.nvim_set_current_win(panes.right_win)
- local map_buf = commands.review_toggle_layout()
- assert.is_not_nil(map_buf)
- table.insert(test_buffers, map_buf)
- local map_win = find_window_for_buffer(map_buf)
- assert.is_not_nil(map_win)
- local cursor_line = vim.api.nvim_win_get_cursor(map_win)[1]
- local cursor_text = buffer_lines(map_buf)[cursor_line]
- local loc = loclist_items(map_win)
-
- assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf))
- assert.are.equal('single', vim.api.nvim_buf_get_var(map_buf, 'diffs_rail_style'))
- assert.are.same(
- { display = repo.base .. '..' .. repo.target, layout = 'stacked' },
- vim.b[map_buf].diffs_review
- )
- assert.is_true(helpers.has_keymap(map_buf, 'gs'))
- assert.is_true(cursor_text:find('+line 2 changed', 1, true) ~= nil)
- assert.is_true(#loc >= 1)
- assert.are.equal(map_buf, loc[1].bufnr)
- assert.is_true(loc[1].text:find('lua/two.lua', 1, true) ~= nil)
- end)
-
- it('toggles a direct split review back to a unified review map', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt')
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split HEAD')
- local panes = track_panes(left_buf)
-
- vim.api.nvim_set_current_win(panes.right_win)
- local map_buf = commands.review_toggle_layout()
- assert.is_not_nil(map_buf)
- table.insert(test_buffers, map_buf)
-
- assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf))
- assert.are.equal('dual', vim.api.nvim_buf_get_var(map_buf, 'diffs_rail_style'))
- assert.are.same({ display = 'HEAD', layout = 'unified' }, vim.b[map_buf].diffs_review)
- assert.is_true(buffer_text(map_buf):find('diff --git a/file.txt b/file.txt', 1, true) ~= nil)
- end)
-
- local function create_block_review_repo()
- local repo_root = create_repo()
- write_repo_file(repo_root, 'lua/block.lua', {
- 'one',
- 'two',
- 'three',
- 'four',
- 'five',
- 'six',
- })
- git_cmd(repo_root, { 'add', 'lua/block.lua' })
- git_cmd(repo_root, { 'commit', '-qm', 'block' })
- write_repo_file(repo_root, 'lua/block.lua', {
- 'one',
- 'two changed',
- 'three changed',
- 'five',
- 'six',
- })
- return repo_root
- end
-
- ---@param repo_root string
- ---@param text string
- ---@return integer, integer, integer
- local function toggle_from_review_line(repo_root, text)
- edit_file(repo_root .. '/lua/block.lua')
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({ base = 'HEAD', repo = repo_root })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- local review_win = find_window_for_buffer(review_buf)
- assert.is_not_nil(review_win)
- local review_line = find_buffer_line(review_buf, text)
- assert.is_not_nil(review_line)
- vim.api.nvim_set_current_win(review_win)
- vim.api.nvim_win_set_cursor(review_win, { review_line, 0 })
-
- local panes = track_panes(commands.review_toggle_layout())
- vim.api.nvim_set_current_win(panes.right_win)
- local split_row = vim.api.nvim_win_get_cursor(panes.right_win)[1]
-
- local map_buf = commands.review_toggle_layout()
- assert.is_not_nil(map_buf)
- table.insert(test_buffers, map_buf)
- local map_win = find_window_for_buffer(map_buf)
- assert.is_not_nil(map_win)
-
- return review_line, split_row, vim.api.nvim_win_get_cursor(map_win)[1]
- end
-
- it('opens the split at the selected review line and returns to it', function()
- local repo_root = create_block_review_repo()
-
- local review_line, split_row, returned_line =
- toggle_from_review_line(repo_root, '+three changed')
-
- assert.are.equal(3, split_row)
- assert.are.equal(review_line, returned_line)
- end)
-
- it('returns to a deleted review line from the filler row facing it', function()
- local repo_root = create_block_review_repo()
-
- local review_line, _, returned_line = toggle_from_review_line(repo_root, '-four')
-
- assert.are.equal(review_line, returned_line)
- end)
-
- it('settles on the replacing line when a removed line selected the split', function()
- local repo_root = create_block_review_repo()
-
- local first_line, _, second_line = toggle_from_review_line(repo_root, '-two')
- local third_line = select(3, toggle_from_review_line(repo_root, '+two changed'))
-
- assert.are_not.equal(first_line, second_line)
- assert.are.equal(second_line, third_line)
- end)
-
- it('returns to the review line the split cursor moved to', function()
- local repo_root = create_block_review_repo()
- edit_file(repo_root .. '/lua/block.lua')
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({ base = 'HEAD', repo = repo_root })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- local review_win = find_window_for_buffer(review_buf)
- vim.api.nvim_set_current_win(review_win)
- vim.api.nvim_win_set_cursor(review_win, { find_buffer_line(review_buf, '+two changed'), 0 })
-
- local panes = track_panes(commands.review_toggle_layout())
- vim.api.nvim_set_current_win(panes.right_win)
- local moved_row = nil
- for lnum, line in ipairs(vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false)) do
- if line == 'six' then
- moved_row = lnum
- end
- end
- assert.is_not_nil(moved_row)
- vim.api.nvim_win_set_cursor(panes.right_win, { moved_row, 0 })
-
- local map_buf = commands.review_toggle_layout()
- assert.is_not_nil(map_buf)
- table.insert(test_buffers, map_buf)
- local map_win = find_window_for_buffer(map_buf)
- local returned_line = vim.api.nvim_win_get_cursor(map_win)[1]
-
- assert.are.equal(find_buffer_line(map_buf, ' six'), returned_line)
- end)
-
- it('preserves review keys through quickfix jumps and layout toggles', function()
- local repo = create_current_state_review_repo()
- edit_file(repo.repo_root .. '/lua/dup.lua')
- mock_runtime_attach(function() end)
-
- local review_buf = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
- local review_win = find_window_for_buffer(review_buf)
- assert.is_not_nil(review_win)
- vim.api.nvim_set_current_win(review_win)
- vim.cmd('copen')
- local qf_win = vim.api.nvim_get_current_win()
- local qf_buf = vim.api.nvim_get_current_buf()
- vim.api.nvim_win_set_cursor(qf_win, { 5, 0 })
- run_buf_keymap(qf_buf, '')
- assert.are.equal(review_win, vim.api.nvim_get_current_win())
-
- local left_buf = commands.review_toggle_layout()
- local panes = track_panes(left_buf)
- assert.are.equal('unstaged:lua/dup.lua', panes.state.selected_key)
- assert.are.equal(5, vim.fn.getqflist({ idx = 0 }).idx)
- local wins = main_windows()
-
- for _, row in ipairs({ 3, 5, 5 }) do
- vim.api.nvim_set_current_win(qf_win)
- vim.api.nvim_win_set_cursor(qf_win, { row, 0 })
- run_buf_keymap(qf_buf, '')
- panes = track_panes(panes.state.left_buf)
- local item = quickfix_items()[row]
- assert.are.equal(item.user_data.diffs.key, panes.state.selected_key)
- assert.are.same(item.user_data.diffs.diff_spec, vim.b[panes.right_buf].diffs_source.spec)
- assert.are.equal(panes.right_win, vim.api.nvim_get_current_win())
- assert.are.equal(row, vim.fn.getqflist({ idx = 0 }).idx)
- assert.are.same(wins, main_windows())
- assert.is_nil(visible_review_map())
- end
-
- commands.review_prev_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.equal(4, vim.fn.getqflist({ idx = 0 }).idx)
- assert.are.equal(4, vim.api.nvim_win_get_cursor(qf_win)[1])
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.equal(5, vim.fn.getqflist({ idx = 0 }).idx)
- assert.are.equal(5, vim.api.nvim_win_get_cursor(qf_win)[1])
-
- vim.api.nvim_set_current_win(panes.left_win)
- local map_buf = commands.review_toggle_layout()
- assert.is_not_nil(map_buf)
- table.insert(test_buffers, map_buf)
- local map_win = find_window_for_buffer(map_buf)
- assert.is_not_nil(map_win)
- local expected_line = find_buffer_line_after(map_buf, '# Unstaged:', '-dup staged')
-
- assert.are.equal(expected_line, vim.api.nvim_win_get_cursor(map_win)[1])
- end)
-
- it('reports nil when toggling outside a review surface', function()
- local repo_root = create_repo()
- local bufnr = edit_file(repo_root .. '/file.txt')
- local notifications = capture_notifications()
-
- local opened = commands.review_toggle_layout()
-
- assert.is_nil(opened)
- assert.are.equal(bufnr, vim.api.nvim_get_current_buf())
- assert.is_true(
- notifications[#notifications].message:find('generated diff file index', 1, true) ~= nil
- )
- end)
-
- it('does not fall back to another diffs window for an invalid replacement window', function()
- local repo = create_review_repo()
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local opened = commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'direct',
- repo = repo.repo_root,
- }, {
- replace_win = 999999,
- })
-
- assert.is_nil(opened)
- assert.is_true(
- notifications[#notifications].message:find('replacement diff window', 1, true) ~= nil
- )
- end)
-
- it('closes an existing review split workspace before opening another spec', function()
- local repo = create_review_repo()
- mock_repo_root(function()
- return repo.repo_root
- end)
- mock_runtime_attach(function() end)
-
- local first =
- commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target))
- local first_panes = track_panes(first)
- local first_left = first_panes.left_buf
- local first_right = first_panes.right_buf
-
- local second =
- commands.review_command(('++layout=split %s...%s'):format(repo.base, repo.target))
- local second_panes = track_panes(second)
-
- assert.is_false(vim.api.nvim_buf_is_valid(first_left))
- assert.is_false(vim.api.nvim_buf_is_valid(first_right))
- assert.is_nil(commands._test.review_split_state(first_left))
- assert.is_not_nil(commands._test.review_split_state(second))
- assert.are.equal(2, #main_windows())
- assert.is_not_nil(find_window_for_buffer(second_panes.left_buf))
- assert.is_not_nil(find_window_for_buffer(second_panes.right_buf))
- end)
-
- it('skips unsupported default review entries when opening direct split workspaces', function()
- local repo_root = create_binary_first_review_repo()
- edit_file(repo_root .. '/zzz-changed.lua')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command('++layout=split binary-first-base..binary-first-topic')
- local panes = track_panes(left_buf)
-
- assert.are.same(
- diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.rev_to_rev('binary-first-base', 'binary-first-topic', 'zzz-changed.lua'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- assert.are.same({ 'old' }, vim.api.nvim_buf_get_lines(panes.left_buf, 0, -1, false))
- assert.are.same({ 'new' }, vim.api.nvim_buf_get_lines(panes.right_buf, 0, -1, false))
- assert.are.equal(
- '[diffs]: review skipped 1 file(s): aaa-bin.dat\n[diffs]: (2 of 2): zzz-changed.lua',
- notifications[1].message
- )
- end)
-
- it('marks skipped review files in the quickfix file index', function()
- local repo_root = create_binary_review_repo()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = 'binary-base',
- target = 'binary-topic',
- mode = 'direct',
- repo = repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local qf = quickfix_items()
- assert.are.equal(3, #qf)
- assert.are.equal('aaa-one.lua', vim.trim(qf[1].module))
- assert.are.equal('+1 -1', qf[1].text)
- assert.are.equal('bbb-bin.dat (skipped)', vim.trim(qf[2].module))
- assert.are.equal('ccc-two.lua', vim.trim(qf[3].module))
- assert.are.equal('+1 -1', qf[3].text)
- assert.is_false(qf[1].user_data.diffs.skipped)
- assert.is_true(qf[2].user_data.diffs.skipped)
- assert.is_false(qf[3].user_data.diffs.skipped)
- end)
-
- it('routes current-state duplicate paths by section in the two-surface workspace', function()
- local repo = create_current_state_review_repo()
- mock_runtime_attach(function() end)
-
- local function open_section(header, expected_spec)
- local review_buf = commands.review({
- base = repo.base,
- repo = repo.repo_root,
- })
- assert.is_not_nil(review_buf)
- table.insert(test_buffers, review_buf)
-
- local line =
- find_buffer_line_after(review_buf, header, 'diff --git a/lua/dup.lua b/lua/dup.lua')
- assert.is_not_nil(line)
- vim.api.nvim_win_set_cursor(0, { line, 0 })
-
- local left_buf = commands.review_split({ bufnr = review_buf })
- local panes = track_panes(left_buf)
- assert.are.same(
- expected_spec,
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- expected_spec,
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- assert.is_not_nil(find_window_for_buffer(panes.left_buf))
- assert.is_not_nil(find_window_for_buffer(panes.right_buf))
- assert.is_nil(find_window_for_buffer(review_buf))
- assert.is_nil(visible_review_map())
- split.close_pair(panes.left_buf)
- end
-
- open_section('# Branch:', diffspec.rev_to_rev(repo.base, 'HEAD', 'lua/dup.lua'))
- open_section('# Staged:', diffspec.head_to_index('lua/dup.lua'))
- open_section('# Unstaged:', diffspec.index_to_worktree('lua/dup.lua'))
- end)
-
- it('uses ]q/[q and ]f/[f to switch review files and loclist for active-file hunks', function()
- local repo = create_review_repo()
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target))
- local panes = track_panes(left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
-
- assert.are.equal(0, #quickfix_items())
- assert.is_true(helpers.has_keymap(panes.left_buf, ']f'))
- assert.is_true(helpers.has_keymap(panes.right_buf, ']f'))
- assert.is_true(helpers.has_keymap(panes.left_buf, '[f'))
- assert.is_true(helpers.has_keymap(panes.right_buf, '[f'))
- assert.is_true(helpers.has_keymap(panes.left_buf, ']q'))
- assert.is_true(helpers.has_keymap(panes.right_buf, ']q'))
- assert.is_true(helpers.has_keymap(panes.left_buf, '[q'))
- assert.is_true(helpers.has_keymap(panes.right_buf, '[q'))
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- assert.is_true(buffer_text(panes.right_buf):find('line 11 changed', 1, true) ~= nil)
- assert.are.equal(0, #quickfix_items())
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_prev_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
-
- assert_target_at_hunk(panes, 1)
-
- vim.api.nvim_set_current_win(panes.left_win)
- vim.cmd('lopen')
- local loc_win = find_loclist_window_for_window(panes.left_win)
- assert.is_not_nil(loc_win)
- local loc = loclist_items(panes.left_win)
- assert.are.equal(2, #loc)
- assert.are.equal(panes.left_buf, loc[1].bufnr)
- local rloc = loclist_items(panes.right_win)
- assert.are.equal(2, #rloc)
- assert.are.equal(panes.right_buf, rloc[1].bufnr)
- vim.cmd('lclose')
-
- vim.api.nvim_set_current_win(panes.right_win)
- run_buf_keymap(panes.right_buf, ']c')
- assert_target_at_hunk(panes, 2)
- run_buf_keymap(panes.right_buf, '[c')
- assert_target_at_hunk(panes, 1)
- end)
-
- it('skips unsupported files when switching review files', function()
- local repo_root = create_binary_review_repo()
- edit_file(repo_root .. '/aaa-one.lua')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split binary-base..binary-topic')
- local panes = track_panes(left_buf)
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'ccc-two.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level)
- assert.are.equal(
- '[diffs]: review skipped 1 file(s): bbb-bin.dat\n[diffs]: (3 of 3): ccc-two.lua',
- notifications[#notifications].message
- )
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_prev_file()
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.equal(vim.log.levels.INFO, notifications[#notifications].level)
- assert.are.equal(
- '[diffs]: review skipped 1 file(s): bbb-bin.dat\n[diffs]: (1 of 3): aaa-one.lua',
- notifications[#notifications].message
- )
- end)
-
- it('keeps the skipped-file message when no candidate switches', function()
- local repo_root = create_binary_review_repo({ trailing = false })
- edit_file(repo_root .. '/aaa-one.lua')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split binary-base..binary-topic')
- local panes = track_panes(left_buf)
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
-
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
-
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.equal(1, #notifications)
- assert.are.equal(vim.log.levels.INFO, notifications[1].level)
- assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message)
- end)
-
- it('marks skipped review files for the API and picker', function()
- local repo_root = create_binary_review_repo()
- edit_file(repo_root .. '/aaa-one.lua')
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split binary-base..binary-topic')
- local panes = track_panes(left_buf)
- local files = commands.review_files(panes.left_buf)
- assert.are.equal(3, #files)
- assert.is_false(files[1].skipped)
- assert.is_true(files[2].skipped)
- assert.is_false(files[3].skipped)
-
- local formatted = {}
- local original = vim.ui.select
- vim.ui.select = function(items, opts, on_choice)
- formatted[1] = opts.format_item(items[1])
- formatted[2] = opts.format_item(items[2])
- formatted[3] = opts.format_item(items[3])
- on_choice(items[2])
- end
- local ok, err = pcall(commands.select_review_file, panes.left_buf)
- vim.ui.select = original
- assert.is_true(ok, err)
-
- assert.is_true(formatted[1]:match('^aaa%-one%.lua%s+%+1%s+%-1$') ~= nil)
- assert.are.equal('bbb-bin.dat (skipped)', formatted[2])
- assert.is_true(formatted[3]:match('^ccc%-two%.lua%s+%+1%s+%-1$') ~= nil)
- assert.are.same(
- diffspec.rev_to_rev('binary-base', 'binary-topic', 'aaa-one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.equal(1, #notifications)
- assert.are.equal('[diffs]: review skipped 1 file(s): bbb-bin.dat', notifications[1].message)
- end)
-
- it('exposes review_files/current/goto, the gO map, and the b:diffs_review marker', function()
- local repo = create_review_repo()
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target))
- local panes = track_panes(left_buf)
-
- for _, buf in ipairs({ panes.left_buf, panes.right_buf }) do
- local marker = vim.b[buf].diffs_review
- assert.is_table(marker)
- assert.are.equal('split', marker.layout)
- assert.is_true(helpers.has_keymap(buf, 'gO'))
- assert.is_true(helpers.has_keymap(buf, 'gs'))
- end
-
- local files = commands.review_files(panes.left_buf)
- assert.are.equal(2, #files)
- assert.are.equal('lua/one.lua', files[1].path)
- assert.are.equal('lua/two.lua', files[2].path)
- assert.is_string(files[1].key)
- assert.is_number(files[1].added)
- assert.is_number(files[1].removed)
-
- local current = commands.review_current(panes.left_buf)
- assert.are.equal(1, current.index)
- assert.are.equal(2, current.count)
- assert.are.equal('lua/one.lua', current.file.path)
-
- assert.is_true(commands.review_goto(files[2].key, panes.left_buf))
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.equal(2, commands.review_current(panes.left_buf).index)
-
- assert.is_false(commands.review_goto('does/not/exist', panes.left_buf))
- local scratch = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, scratch)
- assert.is_nil(commands.review_files(scratch))
- assert.is_false(commands.review_goto(files[1].key, scratch))
- end)
-
- it('navigates repeated paths by their review section', function()
- local repo = create_current_state_review_repo()
- edit_file(repo.repo_root .. '/lua/dup.lua')
- mock_runtime_attach(function() end)
-
- local panes = track_panes(commands.review_command('++layout=split ' .. repo.base))
- local files = commands.review_files(panes.right_buf)
- for index, file in ipairs(files) do
- assert.are.equal(index, commands.review_current(panes.right_buf).index)
- assert.are.equal(file.key, panes.state.selected_key)
- vim.api.nvim_set_current_win(panes.right_win)
- commands.review_next_file()
- panes = track_panes(panes.state.left_buf)
- end
- assert.are.equal(1, commands.review_current(panes.right_buf).index)
-
- for _, key in ipairs({ 'branch:lua/dup.lua', 'staged:lua/dup.lua', 'unstaged:lua/dup.lua' }) do
- assert.is_true(commands.review_goto(key, panes.right_buf))
- panes = track_panes(panes.state.left_buf)
- assert.are.equal(key, commands.review_current(panes.right_buf).file.key)
- end
- end)
-
- it('select_review_file jumps to the chosen file via vim.ui.select', function()
- local repo = create_review_repo()
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target))
- local panes = track_panes(left_buf)
-
- local original = vim.ui.select
- vim.ui.select = function(items, _, on_choice)
- on_choice(items[2])
- end
- local ok, err = pcall(commands.select_review_file, panes.left_buf)
- vim.ui.select = original
- assert.is_true(ok, err)
-
- panes = track_panes(panes.state.left_buf)
- assert.are.same(
- diffspec.rev_to_rev(repo.base, repo.target, 'lua/two.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- end)
-
- it('navigates loclist hunks without switching the active review file', function()
- local repo = create_review_repo()
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command(('++layout=split %s..%s'):format(repo.base, repo.target))
- local panes = track_panes(left_buf)
- assert.are.equal('lua/one.lua', panes.state.selected_file)
-
- vim.api.nvim_set_current_win(panes.left_win)
- vim.cmd('lopen')
- local loc_win = find_loclist_window_for_window(panes.left_win)
- assert.is_not_nil(loc_win)
- local loc = loclist_items(panes.left_win)
- assert.is_true(#loc >= 1)
-
- vim.api.nvim_set_current_win(loc_win)
- vim.api.nvim_win_set_cursor(loc_win, { #loc, 0 })
- local ok = pcall(function()
- vim.cmd('normal \r')
- end)
- vim.wait(50)
-
- assert.is_true(ok)
- assert.are.equal('lua/one.lua', panes.state.selected_file)
- assert.is_true(vim.api.nvim_buf_is_valid(panes.left_buf))
- assert.is_true(vim.api.nvim_buf_is_valid(panes.right_buf))
- end)
-
- it('closes both review split surfaces from the generated diff buffer', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2 changed' }, repo_root .. '/file.txt')
- edit_file(repo_root .. '/file.txt')
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command('++layout=split HEAD')
- local panes = track_panes(left_buf)
-
- vim.api.nvim_set_current_win(panes.right_win)
- run_buf_keymap(panes.right_buf, 'q')
-
- assert.is_false(vim.api.nvim_buf_is_valid(panes.left_buf))
- assert.is_false(vim.api.nvim_buf_is_valid(panes.right_buf))
- assert.is_nil(commands._test.review_split_state(panes.left_buf))
- assert.is_nil(commands._test.review_split_state(panes.right_buf))
- end)
-
- it('skips binary untracked files in current-state reviews', function()
- local repo_root = create_repo()
- write_repo_file(repo_root, 'lua/new.lua', { 'new text' })
- write_binary_file(repo_root .. '/bin.dat', 'binary\\000new')
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = 'HEAD',
- repo = repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_true(text:find('# Untracked:', 1, true) ~= nil)
- assert.is_true(text:find('diff --git a/lua/new.lua b/lua/new.lua', 1, true) ~= nil)
- assert.is_true(text:find('+new text', 1, true) ~= nil)
- assert.is_false(text:find('bin.dat', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(1, #qf)
- assert.are.equal('lua/new.lua', vim.trim(qf[1].module))
- end)
-
- it('routes current-state conflict reviews to read-only unmerged target views', function()
- local repo_root = create_conflicted_repo()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = 'HEAD',
- repo = repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local text = buffer_text(bufnr)
- assert.is_false(text:find('# Staged:', 1, true) ~= nil)
- assert.is_true(text:find('# Unstaged:', 1, true) ~= nil)
- assert.is_true(text:find('-line 2 theirs', 1, true) ~= nil)
- assert.is_true(text:find('+line 2 ours', 1, true) ~= nil)
-
- local qf = quickfix_items()
- assert.are.equal(1, #qf)
- assert.are.equal('unstaged', qf[1].user_data.diffs.section)
- assert.are.same(diffspec.rev_to_rev(':2', ':3', 'file.txt'), qf[1].user_data.diffs.diff_spec)
-
- local header_line = find_buffer_line(bufnr, '# Unstaged:')
- assert.is_not_nil(header_line)
- vim.api.nvim_win_set_cursor(0, { header_line, 0 })
- local left_buf = commands.review_split({ bufnr = bufnr })
- local panes = track_panes(left_buf)
-
- assert.are.same(
- diffspec.rev_to_rev(':2', ':3', 'file.txt'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.rev_to_rev(':2', ':3', 'file.txt'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- assert.is_true(buffer_text(panes.left_buf):find('line 2 theirs', 1, true) ~= nil)
- assert.is_true(buffer_text(panes.right_buf):find('line 2 ours', 1, true) ~= nil)
- end)
-
- it('opens a base-only current-state review file as the selected section edge', function()
- local repo = create_review_repo({ commit_target = false })
- edit_file(repo.repo_root .. '/lua/two.lua')
- mock_runtime_attach(function() end)
-
- local left_buf = commands.review_command(('++layout=split %s'):format(repo.base))
- local panes = track_panes(left_buf)
-
- assert.are.same(
- diffspec.index_to_worktree('lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.index_to_worktree('lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- end)
-
- it('opens a merge-base review file from the resolved merge base to the target tree', function()
- local repo = create_review_repo({ named_refs = true })
- edit_file(repo.repo_root .. '/lua/one.lua')
- mock_runtime_attach(function() end)
-
- local left_buf =
- commands.review_command(('++layout=split %s...%s'):format(repo.base, repo.target))
- local panes = track_panes(left_buf)
-
- assert.are.same(
- diffspec.rev_to_rev(repo.base_sha, repo.target, 'lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.left_buf, 'diffs_source').spec
- )
- assert.are.same(
- diffspec.rev_to_rev(repo.base_sha, repo.target, 'lua/one.lua'),
- vim.api.nvim_buf_get_var(panes.right_buf, 'diffs_source').spec
- )
- end)
-
- it('reports skipped selected review files without opening a workspace', function()
- local repo = create_mode_only_review_repo()
- local notifications = capture_notifications()
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = repo.base,
- target = repo.target,
- mode = 'direct',
- repo = repo.repo_root,
- })
- assert.is_not_nil(bufnr)
- table.insert(test_buffers, bufnr)
-
- local mode_line = find_buffer_line(bufnr, 'diff --git a/scripts/tool.sh b/scripts/tool.sh')
- assert.is_not_nil(mode_line)
- vim.api.nvim_win_set_cursor(0, { mode_line, 0 })
-
- local opened = commands.review_split({ bufnr = bufnr })
-
- assert.is_nil(opened)
- assert.are.equal(
- '[diffs]: review skipped 1 file(s): scripts/tool.sh',
- notifications[1].message
- )
- assert.is_nil(commands._test.review_split_state(bufnr))
- end)
-
- it('keeps no-hunk review file records in quickfix with an empty active loclist', function()
- mock_repo_root(function(path)
- assert.are.equal('/tmp/repo/.', path)
- return '/tmp/repo'
- end)
- mock_systemlist(function(cmd)
- if cmd[4] == 'rev-parse' then
- return { 'commit' }
- end
- if cmd[4] == 'merge-base' then
- return { 'merge-base-commit' }
- end
- if cmd[4] ~= 'diff' then
- return {}
- end
- return {
- 'diff --git a/lua/mode.lua b/lua/mode.lua',
- 'old mode 100644',
- 'new mode 100755',
- 'diff --git a/lua/changed.lua b/lua/changed.lua',
- '--- a/lua/changed.lua',
- '+++ b/lua/changed.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- }
- end)
- mock_runtime_attach(function() end)
-
- local bufnr = commands.review({
- base = 'origin/main',
- target = 'refs/forge/pr/42',
- mode = 'merge-base',
- repo = '/tmp/repo',
- })
- table.insert(test_buffers, bufnr)
-
- local qf = quickfix_items()
- assert.are.equal(2, #qf)
- assert.are.equal(bufnr, qf[1].bufnr)
- assert.are.equal(1, qf[1].lnum)
- assert.are.equal('lua/mode.lua (skipped)', vim.trim(qf[1].module))
- assert.is_true(qf[1].user_data.diffs.skipped)
- assert.are.equal(bufnr, qf[2].bufnr)
- assert.are.equal(4, qf[2].lnum)
- assert.are.equal('lua/changed.lua', vim.trim(qf[2].module))
- assert.is_false(qf[2].user_data.diffs.skipped)
-
- assert.are.equal(0, #loclist_items())
-
- vim.api.nvim_win_set_cursor(0, { 4, 0 })
- vim.api.nvim_exec_autocmds('CursorMoved', { buffer = bufnr })
-
- local loc = loclist_items()
- assert.are.equal(1, #loc)
- assert.are.equal(7, loc[1].lnum)
- assert.is_true(loc[1].text:find('lua/changed.lua', 1, true) ~= nil)
- end)
- end)
-
- describe('review_file_at_line', function()
- local review_test_buffers = {}
-
- after_each(function()
- for _, bufnr in ipairs(review_test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- review_test_buffers = {}
- end)
-
- it('returns filename at cursor line', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local x = 1',
- ' return M',
- })
-
- assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 6))
- end)
-
- it('returns correct file in multi-file diff', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'diff --git a/foo.lua b/foo.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- 'diff --git a/bar.lua b/bar.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- })
-
- assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 3))
- assert.are.equal('bar.lua', commands.review_file_at_line(bufnr, 7))
- end)
-
- it('returns nil before any diff header', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'some preamble text',
- 'diff --git a/foo.lua b/foo.lua',
- })
-
- assert.is_nil(commands.review_file_at_line(bufnr, 1))
- end)
-
- it('returns nil on empty buffer', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(review_test_buffers, bufnr)
-
- assert.is_nil(commands.review_file_at_line(bufnr, 1))
- end)
- end)
-
- describe('find_hunk_line', function()
- it('finds matching @@ header and returns target line', function()
- local diff_lines = {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- }
- local hunk_position = {
- hunk_header = '@@ -1,3 +1,4 @@',
- offset = 2,
- }
- local target_line = commands.find_hunk_line(diff_lines, hunk_position)
- assert.equals(6, target_line)
- end)
-
- it('returns nil when hunk header not found', function()
- local diff_lines = {
- 'diff --git a/file.lua b/file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- }
- local hunk_position = {
- hunk_header = '@@ -99,3 +99,4 @@',
- offset = 1,
- }
- local target_line = commands.find_hunk_line(diff_lines, hunk_position)
- assert.is_nil(target_line)
- end)
-
- it('handles multiple hunks and finds correct one', function()
- local diff_lines = {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local x = 1',
- ' ',
- '@@ -10,3 +11,4 @@',
- ' function M.foo()',
- '+ print("hello")',
- ' end',
- }
- local hunk_position = {
- hunk_header = '@@ -10,3 +11,4 @@',
- offset = 2,
- }
- local target_line = commands.find_hunk_line(diff_lines, hunk_position)
- assert.equals(10, target_line)
- end)
- end)
-end)
diff --git a/spec/config_spec.lua b/spec/config_spec.lua
index 0b8ac7b5..5b92404f 100644
--- a/spec/config_spec.lua
+++ b/spec/config_spec.lua
@@ -40,7 +40,6 @@ describe('diffs.config', function()
committia = true,
telescope = true,
},
- conflict = false,
})
end)
end)
@@ -80,12 +79,6 @@ describe('diffs.config', function()
assert.is_nil(opts.hide_prefix)
end)
- it('accepts boolean conflict settings', function()
- assert.is_true(config.new().conflict)
- assert.is_true(config.new({ conflict = true }).conflict)
- assert.is_false(config.new({ conflict = false }).conflict)
- end)
-
it('accepts integration lists and boolean maps', function()
for _, key in ipairs(integration_keys) do
for _, integrations in ipairs({ { key }, { [key] = true }, { [key] = false } }) do
diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua
deleted file mode 100644
index 176337f5..00000000
--- a/spec/conflict_spec.lua
+++ /dev/null
@@ -1,715 +0,0 @@
-local conflict = require('diffs.conflict')
-local helpers = require('spec.helpers')
-
-local function create_file_buffer(lines)
- local bufnr = vim.api.nvim_create_buf(false, false)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
- return bufnr
-end
-
-local function get_extmarks(bufnr)
- return vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
-end
-
-describe('conflict', function()
- describe('parse', function()
- it('parses a single conflict', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- assert.are.equal(0, regions[1].marker_ours)
- assert.are.equal(1, regions[1].ours_start)
- assert.are.equal(2, regions[1].ours_end)
- assert.are.equal(2, regions[1].marker_sep)
- assert.are.equal(3, regions[1].theirs_start)
- assert.are.equal(4, regions[1].theirs_end)
- assert.are.equal(4, regions[1].marker_theirs)
- end)
-
- it('parses multiple conflicts', function()
- local lines = {
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- 'normal line',
- '<<<<<<< HEAD',
- 'c',
- '=======',
- 'd',
- '>>>>>>> feat',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(2, #regions)
- assert.are.equal(0, regions[1].marker_ours)
- assert.are.equal(6, regions[2].marker_ours)
- end)
-
- it('parses diff3 format', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '||||||| base',
- 'local x = 0',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- assert.are.equal(2, regions[1].marker_base)
- assert.are.equal(3, regions[1].base_start)
- assert.are.equal(4, regions[1].base_end)
- end)
-
- it('handles empty ours section', function()
- local lines = {
- '<<<<<<< HEAD',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- assert.are.equal(1, regions[1].ours_start)
- assert.are.equal(1, regions[1].ours_end)
- end)
-
- it('handles empty theirs section', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- assert.are.equal(3, regions[1].theirs_start)
- assert.are.equal(3, regions[1].theirs_end)
- end)
-
- it('returns empty for no markers', function()
- local lines = { 'local x = 1', 'local y = 2' }
- local regions = conflict.parse(lines)
- assert.are.equal(0, #regions)
- end)
-
- it('discards malformed markers (no separator)', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(0, #regions)
- end)
-
- it('discards malformed markers (no end)', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(0, #regions)
- end)
-
- it('handles trailing text on marker lines', function()
- local lines = {
- '<<<<<<< HEAD (some text)',
- 'local x = 1',
- '======= extra',
- 'local x = 2',
- '>>>>>>> feature-branch/some-thing',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- end)
-
- it('handles empty base in diff3', function()
- local lines = {
- '<<<<<<< HEAD',
- 'local x = 1',
- '||||||| base',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }
- local regions = conflict.parse(lines)
- assert.are.equal(1, #regions)
- assert.are.equal(3, regions[1].base_start)
- assert.are.equal(3, regions[1].base_end)
- end)
- end)
-
- describe('highlighting', function()
- after_each(function()
- conflict.detach(vim.api.nvim_get_current_buf())
- end)
-
- it('applies extmarks for conflict regions', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
-
- conflict.attach(bufnr)
-
- local extmarks = get_extmarks(bufnr)
- assert.is_true(#extmarks > 0)
-
- local has_ours = false
- local has_theirs = false
- local has_marker = false
- for _, mark in ipairs(extmarks) do
- local hl = mark[4] and mark[4].hl_group
- if hl == 'DiffsConflictOurs' then
- has_ours = true
- end
- if hl == 'DiffsConflictTheirs' then
- has_theirs = true
- end
- if hl == 'DiffsConflictMarker' then
- has_marker = true
- end
- end
- assert.is_true(has_ours)
- assert.is_true(has_theirs)
- assert.is_true(has_marker)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('uses fixed extmark priority for conflict regions', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
-
- conflict.attach(bufnr)
-
- local checked = 0
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local details = mark[4]
- if details and (details.hl_group or details.number_hl_group) then
- assert.are.equal(200, details.priority)
- checked = checked + 1
- end
- end
- assert.is_true(checked > 0)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('applies number_hl_group to content lines', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
-
- conflict.attach(bufnr)
-
- local extmarks = get_extmarks(bufnr)
- local has_ours_nr = false
- local has_theirs_nr = false
- for _, mark in ipairs(extmarks) do
- local nr = mark[4] and mark[4].number_hl_group
- if nr == 'DiffsConflictOursNr' then
- has_ours_nr = true
- end
- if nr == 'DiffsConflictTheirsNr' then
- has_theirs_nr = true
- end
- end
- assert.is_true(has_ours_nr)
- assert.is_true(has_theirs_nr)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('highlights base region in diff3', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '||||||| base',
- 'local x = 0',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
-
- conflict.attach(bufnr)
-
- local extmarks = get_extmarks(bufnr)
- local has_base = false
- for _, mark in ipairs(extmarks) do
- if mark[4] and mark[4].hl_group == 'DiffsConflictBase' then
- has_base = true
- break
- end
- end
- assert.is_true(has_base)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('clears extmarks on detach', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
-
- conflict.attach(bufnr)
- assert.is_true(#get_extmarks(bufnr) > 0)
-
- conflict.detach(bufnr)
- assert.are.equal(0, #get_extmarks(bufnr))
-
- helpers.delete_buffer(bufnr)
- end)
- end)
-
- describe('resolution', function()
- local function make_conflict_buffer()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_set_current_buf(bufnr)
- return bufnr
- end
-
- it('resolve_ours keeps ours content', function()
- local bufnr = make_conflict_buffer()
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_ours(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('local x = 1', lines[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('resolve_theirs keeps theirs content', function()
- local bufnr = make_conflict_buffer()
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_theirs(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('local x = 2', lines[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('resolve_both keeps ours then theirs', function()
- local bufnr = make_conflict_buffer()
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_both(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(2, #lines)
- assert.are.equal('local x = 1', lines[1])
- assert.are.equal('local x = 2', lines[2])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('resolve_none removes entire block', function()
- local bufnr = make_conflict_buffer()
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_none(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('', lines[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('does nothing when cursor is outside conflict', function()
- local bufnr = create_file_buffer({
- 'normal line',
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- conflict.resolve_ours(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(6, #lines)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('resolves one conflict among multiple', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- 'middle',
- '<<<<<<< HEAD',
- 'c',
- '=======',
- 'd',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_ours(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal('a', lines[1])
- assert.are.equal('middle', lines[2])
- assert.are.equal('<<<<<<< HEAD', lines[3])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('resolve_ours with empty ours section', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- conflict.resolve_ours(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('', lines[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('handles diff3 resolution (ignores base)', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '||||||| base',
- 'local x = 0',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
-
- conflict.resolve_theirs(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('local x = 2', lines[1])
-
- helpers.delete_buffer(bufnr)
- end)
- end)
-
- describe('navigation', function()
- it('goto_next jumps to next conflict', function()
- local bufnr = create_file_buffer({
- 'normal',
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- 'middle',
- '<<<<<<< HEAD',
- 'c',
- '=======',
- 'd',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- conflict.goto_next(bufnr)
- assert.are.equal(2, vim.api.nvim_win_get_cursor(0)[1])
-
- conflict.goto_next(bufnr)
- assert.are.equal(8, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_next wraps to first conflict', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- conflict.goto_next(bufnr)
- assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_next notifies on wrap-around', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('wrapped to first conflict') then
- notified = true
- end
- end
-
- conflict.goto_next(bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_prev jumps to previous conflict', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- 'middle',
- '<<<<<<< HEAD',
- 'c',
- '=======',
- 'd',
- '>>>>>>> feat',
- 'end',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 12, 0 })
-
- conflict.goto_prev(bufnr)
- assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
-
- conflict.goto_prev(bufnr)
- assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_prev wraps to last conflict', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- conflict.goto_prev(bufnr)
- assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_prev notifies on wrap-around', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('wrapped to last conflict') then
- notified = true
- end
- end
-
- conflict.goto_prev(bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('goto_next does nothing with no conflicts', function()
- local bufnr = create_file_buffer({ 'normal line' })
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- conflict.goto_next(bufnr)
- assert.are.equal(1, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(bufnr)
- end)
- end)
-
- describe('lifecycle', function()
- it('attach is idempotent', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- conflict.attach(bufnr)
- local count1 = #get_extmarks(bufnr)
- conflict.attach(bufnr)
- local count2 = #get_extmarks(bufnr)
- assert.are.equal(count1, count2)
- conflict.detach(bufnr)
- helpers.delete_buffer(bufnr)
- end)
-
- it('skips non-file buffers', function()
- local bufnr = helpers.create_buffer({
- '<<<<<<< HEAD',
- 'a',
- '=======',
- 'b',
- '>>>>>>> feat',
- })
- vim.api.nvim_set_option_value('buftype', 'nofile', { buf = bufnr })
-
- conflict.attach(bufnr)
- assert.are.equal(0, #get_extmarks(bufnr))
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('skips buffers without conflict markers', function()
- local bufnr = create_file_buffer({ 'local x = 1', 'local y = 2' })
-
- conflict.attach(bufnr)
- assert.are.equal(0, #get_extmarks(bufnr))
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('re-highlights when markers return after resolution', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- local events = {}
- local autocmd = vim.api.nvim_create_autocmd('User', {
- pattern = { 'DiffsConflictDetected', 'DiffsConflictResolved', 'DiffsConflictDetached' },
- callback = function(event)
- assert.are.equal(bufnr, event.data.bufnr)
- events[#events + 1] = event.match
- end,
- })
- vim.api.nvim_set_current_buf(bufnr)
- conflict.attach(bufnr)
-
- assert.is_true(#get_extmarks(bufnr) > 0)
-
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
- conflict.resolve_ours(bufnr)
- assert.are.equal(0, #get_extmarks(bufnr))
- conflict.refresh(bufnr)
-
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr })
-
- assert.is_true(#get_extmarks(bufnr) > 0)
-
- conflict.refresh(bufnr)
- conflict.detach(bufnr)
- assert.are.same({
- 'DiffsConflictDetected',
- 'DiffsConflictResolved',
- 'DiffsConflictDetected',
- 'DiffsConflictDetached',
- }, events)
- vim.api.nvim_del_autocmd(autocmd)
- helpers.delete_buffer(bufnr)
- end)
-
- it('detaches after last conflict resolved', function()
- local bufnr = create_file_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- })
- vim.api.nvim_set_current_buf(bufnr)
- conflict.attach(bufnr)
-
- assert.is_true(#get_extmarks(bufnr) > 0)
-
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
- conflict.resolve_ours(bufnr)
-
- assert.are.equal(0, #get_extmarks(bufnr))
-
- helpers.delete_buffer(bufnr)
- end)
- end)
-end)
diff --git a/spec/content_spec.lua b/spec/content_spec.lua
deleted file mode 100644
index 6cb5705c..00000000
--- a/spec/content_spec.lua
+++ /dev/null
@@ -1,71 +0,0 @@
-require('spec.helpers')
-
-local content = require('diffs.content')
-
-local test_buffers = {}
-local test_files = {}
-
-local function edit_temp_file(lines, flags)
- local path = vim.fn.resolve(vim.fn.tempname())
- test_files[#test_files + 1] = path
- vim.fn.writefile(lines, path, flags or '')
- vim.cmd('edit ' .. vim.fn.fnameescape(path))
- local bufnr = vim.api.nvim_get_current_buf()
- test_buffers[#test_buffers + 1] = bufnr
- return content.from_buffer(bufnr)
-end
-
-describe('diffs.content', function()
- after_each(function()
- for _, bufnr in ipairs(test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- test_buffers = {}
- for _, path in ipairs(test_files) do
- vim.fn.delete(path)
- end
- test_files = {}
- end)
-
- it('normalizes one final newline sentinel', function()
- local lines = content.from_raw_lines({ 'a', '' })
-
- assert.are.equal(1, #lines)
- assert.are.equal('a', lines[1])
- assert.are.equal('a\n', content.to_string(lines))
- end)
-
- it('preserves an intentional trailing blank line', function()
- local lines = content.from_raw_lines({ 'a', '', '' })
-
- assert.are.equal(2, #lines)
- assert.are.equal('a', lines[1])
- assert.are.equal('', lines[2])
- assert.are.equal('a\n\n', content.to_string(lines))
- end)
-
- it('preserves content without a final newline', function()
- local lines = content.from_raw_lines({ 'a' })
-
- assert.are.equal(1, #lines)
- assert.are.equal('a', lines[1])
- assert.are.equal('a', content.to_string(lines))
- end)
-
- it('normalizes empty file reads separately from one-newline files', function()
- local empty = content.from_raw_lines({ '' }, { empty_is_empty = true })
- local newline = content.from_raw_lines({ '', '' })
-
- assert.are.equal('', content.to_string(empty))
- assert.are.equal('\n', content.to_string(newline))
- end)
-
- it('preserves buffer final-newline state', function()
- assert.are.equal('', content.to_string(edit_temp_file({}, 'b')))
- assert.are.equal('\n', content.to_string(edit_temp_file({ '' })))
- assert.are.equal('a', content.to_string(edit_temp_file({ 'a' }, 'b')))
- assert.are.equal('a\n', content.to_string(edit_temp_file({ 'a' })))
- end)
-end)
diff --git a/spec/diff_files_spec.lua b/spec/diff_files_spec.lua
deleted file mode 100644
index 21b72b52..00000000
--- a/spec/diff_files_spec.lua
+++ /dev/null
@@ -1,242 +0,0 @@
-require('spec.helpers')
-
-local commands = require('diffs.commands')
-local generated = require('diffs.generated')
-
-local saved_notify
-local notifications = {}
-
-local function capture_notify()
- saved_notify = vim.notify
- notifications = {}
- vim.notify = function(msg, level)
- table.insert(notifications, { msg = msg, level = level })
- end
-end
-
-local function restore_notify()
- if saved_notify then
- vim.notify = saved_notify
- saved_notify = nil
- end
-end
-
----@return string?
-local function last_message()
- local entry = notifications[#notifications]
- return entry and entry.msg
-end
-
----@param needle string
----@return boolean
-local function last_message_has(needle)
- local msg = last_message()
- return msg ~= nil and msg:find(needle, 1, true) ~= nil
-end
-
-local function diff_lines(bufnr)
- return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
-end
-
-local function has_line(bufnr, pattern)
- for _, line in ipairs(diff_lines(bufnr)) do
- if line:match(pattern) then
- return true
- end
- end
- return false
-end
-
-describe('diffs.commands.diff_files', function()
- local dir
- local a
- local b
-
- before_each(function()
- dir = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(dir, 'p')
- dir = vim.fn.resolve(dir)
- a = dir .. '/a.txt'
- b = dir .. '/b.txt'
- vim.fn.writefile({ 'alpha', 'beta', 'gamma' }, a)
- vim.fn.writefile({ 'alpha', 'BETA', 'gamma', 'delta' }, b)
- capture_notify()
- end)
-
- after_each(function()
- restore_notify()
- -- Let the scheduled runtime.attach run while the buffers are still valid.
- vim.wait(20, function()
- return false
- end)
- for _, buf in ipairs(vim.api.nvim_list_bufs()) do
- local name = vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_get_name(buf) or ''
- if name:match('^diffs://') or name:find(dir, 1, true) then
- pcall(vim.api.nvim_buf_delete, buf, { force = true })
- end
- end
- vim.fn.delete(dir, 'rf')
- end)
-
- it('renders a read-only unified diff of two files outside any repo', function()
- local bufnr = commands.diff_files(a, b, { layout = 'unified' })
-
- assert.is_number(bufnr)
- assert.are.equal('diff', vim.bo[bufnr].filetype)
- assert.is_false(vim.bo[bufnr].modifiable)
- assert.is_true(has_line(bufnr, 'diff %-%-git a/.*a%.txt b/.*b%.txt'))
- assert.is_true(has_line(bufnr, '^.*%+BETA'))
-
- local source = generated.source(bufnr)
- assert.are.equal('files', source.kind)
- assert.are.equal(a, source.left_path)
- assert.are.equal(b, source.right_path)
- end)
-
- it('defaults the new side to live buffer content with a single path', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(a))
- local src = vim.api.nvim_get_current_buf()
- vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' })
-
- local bufnr = commands.diff_files(b, nil, { layout = 'unified' })
-
- assert.is_number(bufnr)
- assert.is_true(has_line(bufnr, '^.*%+UNSAVED'))
-
- local source = generated.source(bufnr)
- assert.are.equal(b, source.left_path)
- assert.are.equal(a, source.right_path)
- assert.are.equal(src, source.right_buf)
- end)
-
- it('compares the current buffer against its last save with no paths', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(a))
- local src = vim.api.nvim_get_current_buf()
- vim.api.nvim_buf_set_lines(src, 2, 3, false, { 'GAMMA' })
-
- local bufnr = commands.diff_files(nil, nil, { layout = 'unified' })
-
- assert.is_number(bufnr)
- assert.is_true(has_line(bufnr, '^.*%-gamma'))
- assert.is_true(has_line(bufnr, '^.*%+GAMMA'))
-
- local source = generated.source(bufnr)
- assert.are.equal(a, source.left_path)
- assert.are.equal(a, source.right_path)
- assert.are.equal(src, source.right_buf)
- assert.is_truthy(vim.api.nvim_buf_get_name(bufnr):find('-> buffer:', 1, true))
- end)
-
- it('reports no changes for an unmodified buffer with no paths', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(a))
-
- local bufnr = commands.diff_files(nil, nil, { layout = 'unified' })
-
- assert.is_nil(bufnr)
- local name = vim.fn.fnamemodify(a, ':~:.')
- assert.is_true(last_message_has('no changes between ' .. name .. ' and buffer:' .. name))
- end)
-
- it('reads both sides from disk when the new side is named', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(b))
- vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'UNSAVED' })
-
- local bufnr = commands.diff_files(a, b, { layout = 'unified' })
-
- assert.is_number(bufnr)
- assert.is_false(has_line(bufnr, 'UNSAVED'))
- assert.is_nil(generated.source(bufnr).right_buf)
- end)
-
- it('allows a buffer-backed side that is not on disk', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(dir .. '/new.txt'))
- vim.api.nvim_buf_set_lines(0, 0, -1, false, { 'alpha', 'beta', 'gamma', 'delta' })
-
- local bufnr = commands.diff_files(a, nil, { layout = 'unified' })
-
- assert.is_number(bufnr)
- assert.is_true(has_line(bufnr, '^.*%+delta'))
- end)
-
- it('refuses a defaulted side in an unnamed buffer', function()
- vim.cmd('enew')
-
- assert.is_nil(commands.diff_files(a, nil, { layout = 'unified' }))
- assert.is_true(last_message_has('cannot diff unnamed buffer'))
- end)
-
- it('re-reads the buffer when the view reloads', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(a))
- local src = vim.api.nvim_get_current_buf()
- vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'first' })
-
- local bufnr = commands.diff_files(b, nil, { layout = 'unified' })
- assert.is_number(bufnr)
-
- vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'second' })
- commands.read_buffer(bufnr)
-
- assert.is_true(has_line(bufnr, '^.*%+second'))
- assert.is_false(has_line(bufnr, '^.*%+first'))
- end)
-
- it('falls back to disk when the buffer is gone at reload', function()
- vim.cmd('edit ' .. vim.fn.fnameescape(a))
- local src = vim.api.nvim_get_current_buf()
- vim.api.nvim_buf_set_lines(src, 0, -1, false, { 'alpha', 'beta', 'UNSAVED' })
-
- local bufnr = commands.diff_files(b, nil, { layout = 'unified' })
- assert.is_number(bufnr)
- assert.is_true(has_line(bufnr, '^.*%+UNSAVED'))
-
- vim.api.nvim_buf_delete(src, { force = true })
- commands.read_buffer(bufnr)
-
- assert.is_false(has_line(bufnr, 'UNSAVED'))
- assert.is_true(has_line(bufnr, '^.*%+beta'))
- end)
-
- it('reloads from disk through the files source', function()
- local bufnr = commands.diff_files(a, b, { layout = 'unified' })
- assert.is_number(bufnr)
-
- vim.fn.writefile({ 'alpha', 'beta', 'gamma', 'delta', 'epsilon' }, b)
- commands.read_buffer(bufnr)
-
- assert.is_true(has_line(bufnr, '^.*%+epsilon'))
- end)
-
- it('reports no changes for identical files without opening a buffer', function()
- local bufnr = commands.diff_files(a, a, { layout = 'unified' })
-
- assert.is_nil(bufnr)
- local name = vim.fn.fnamemodify(a, ':~:.')
- assert.is_true(last_message_has('no changes between ' .. name .. ' and ' .. name))
- end)
-
- it('refuses a directory', function()
- local bufnr = commands.diff_files(dir, b, { layout = 'unified' })
-
- assert.is_nil(bufnr)
- assert.is_true(last_message_has('is a directory; :Diff files compares two files'))
- end)
-
- it('refuses an unreadable path', function()
- local bufnr = commands.diff_files(dir .. '/missing.txt', b, { layout = 'unified' })
-
- assert.is_nil(bufnr)
- assert.is_true(last_message_has('missing.txt: file not readable'))
- end)
-end)
-
-describe('diffs.commands.diff_files_command', function()
- before_each(capture_notify)
- after_each(restore_notify)
-
- it('surfaces parser errors', function()
- local result = commands.diff_files_command('a b c', false)
-
- assert.is_nil(result)
- assert.is_true(last_message_has('expected at most two file paths'))
- end)
-end)
diff --git a/spec/diffargs_spec.lua b/spec/diffargs_spec.lua
deleted file mode 100644
index 53d11d58..00000000
--- a/spec/diffargs_spec.lua
+++ /dev/null
@@ -1,181 +0,0 @@
-require('spec.helpers')
-
-local diffargs = require('diffs.diffargs')
-local diffspec = require('diffs.spec')
-
-describe('diffs.diffargs', function()
- local path = 'lua/foo.lua'
-
- local function parse(args, current)
- local result, err = diffargs.parse(args, {
- path = path,
- current = current or diffspec.worktree(),
- })
- assert.is_nil(err)
- return result
- end
-
- it('maps default worktree :Diff to index -> worktree', function()
- local result = parse(nil)
-
- assert.are.same(diffspec.index_to_worktree(path), result.spec)
- assert.are.equal('unified', result.layout)
- end)
-
- it('maps explicit revisions to revision -> worktree', function()
- local result = parse('HEAD~3')
-
- assert.are.same(diffspec.rev_to_worktree('HEAD~3', path), result.spec)
- end)
-
- it('normalizes @ as HEAD', function()
- local result = parse('@')
-
- assert.are.same(diffspec.rev_to_worktree('HEAD', path), result.spec)
- end)
-
- it('maps current-file index objects to index -> worktree', function()
- for _, object in ipairs({ ':', ':%', ':0:%' }) do
- local result = parse(object)
-
- assert.are.same(diffspec.index_to_worktree(path), result.spec)
- end
- end)
-
- it('maps revision current-file objects to tree -> worktree', function()
- local result = parse('@:%')
-
- assert.are.same(diffspec.rev_to_worktree('HEAD', path), result.spec)
- end)
-
- it('maps staged/index context default to HEAD -> index', function()
- local result = parse(nil, diffspec.index())
-
- assert.are.same(diffspec.head_to_index(path), result.spec)
- end)
-
- it('maps staged/index context @:% to HEAD -> index', function()
- local result = parse('@:%', diffspec.index())
-
- assert.are.same(diffspec.head_to_index(path), result.spec)
- end)
-
- it('rejects --staged as an unsupported flag', function()
- local result, err = diffargs.parse('--staged', { path = path })
-
- assert.is_nil(result)
- assert.are.equal('unsupported option --staged', err)
- end)
-
- it('rejects multiple objects', function()
- local result, err = diffargs.parse('HEAD :0:%', { path = path })
-
- assert.is_nil(result)
- assert.are.equal('expected at most one Fugitive object', err)
- end)
-
- it('maps revision explicit-path objects to revision -> worktree of that path', function()
- local result = parse('HEAD:lua/bar.lua')
-
- assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec)
- end)
-
- it('normalizes @ in revision explicit-path objects', function()
- local result = parse('@:lua/bar.lua')
-
- assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec)
- end)
-
- it('maps index explicit-path objects to index -> worktree of that path', function()
- for _, object in ipairs({ ':lua/bar.lua', ':0:lua/bar.lua' }) do
- local result = parse(object)
-
- assert.are.same(diffspec.index_to_worktree('lua/bar.lua'), result.spec)
- end
- end)
-
- it('maps merge-stage current-file objects to stage -> worktree', function()
- for _, stage in ipairs({ 1, 2, 3 }) do
- local result = parse((':%d:%%'):format(stage))
-
- assert.are.same(diffspec.stage_to_worktree(stage, path), result.spec)
- end
- end)
-
- it('maps merge-stage explicit-path objects to stage -> worktree of that path', function()
- local result = parse(':3:lua/bar.lua')
-
- assert.are.same(diffspec.stage_to_worktree(3, 'lua/bar.lua'), result.spec)
- end)
-
- it('pins explicit-path objects to the worktree even in a staged context', function()
- local result = parse('HEAD:lua/bar.lua', diffspec.index())
-
- assert.are.same(diffspec.rev_to_worktree('HEAD', 'lua/bar.lua'), result.spec)
- end)
-
- local rejected = {
- { object = '#', err = 'alternate-buffer objects (#) are not supported' },
- { object = '#2', err = 'alternate-buffer objects (#) are not supported' },
- { object = '!', err = 'owner-commit objects (!) are not supported' },
- { object = '!:lua/bar.lua', err = 'owner-commit objects (!) are not supported' },
- { object = '', err = ' objects are not supported' },
- { object = '-', err = 'the previous-object form (-) is not supported' },
- { object = './lua/bar.lua', err = 'worktree-relative path objects (./) are not supported' },
- { object = '../lua/bar.lua', err = 'worktree-relative path objects (./) are not supported' },
- {
- object = 'main..other',
- err = 'range objects are not supported; use :Diff review for ranges',
- },
- {
- object = 'main...other',
- err = 'range objects are not supported; use :Diff review for ranges',
- },
- { object = ':2', err = 'merge stage :2 needs a path; use :2:% for the current file' },
- { object = ':/fix', err = 'commit-message search objects (:/) are not supported' },
- { object = ':(top)lua/bar.lua', err = 'pathspec-magic objects (:(...)) are not supported' },
- { object = 'master:', err = 'tree objects (trailing :) are not supported' },
- }
-
- for _, case in ipairs(rejected) do
- it('rejects ' .. case.object, function()
- local result, err = diffargs.parse(case.object, { path = path })
-
- assert.is_nil(result)
- assert.are.equal(case.err, err)
- end)
- end
-end)
-
-describe('diffs.diffargs.parse_files', function()
- it('parses a single path as the old side with the current buffer as new', function()
- local result = diffargs.parse_files('old.lua')
-
- assert.are.equal('old.lua', result.left)
- assert.is_nil(result.right)
- assert.are.equal('unified', result.layout)
- end)
-
- it('parses two paths as old and new', function()
- local result = diffargs.parse_files('old.lua new.lua')
-
- assert.are.equal('old.lua', result.left)
- assert.are.equal('new.lua', result.right)
- assert.are.equal('unified', result.layout)
- end)
-
- it('defaults both sides to the current buffer with no paths', function()
- local bare, bare_err = diffargs.parse_files(nil)
- assert.is_nil(bare_err)
- assert.is_nil(bare.left)
- assert.is_nil(bare.right)
- assert.are.equal('unified', bare.layout)
- end)
-
- it('rejects more than two paths', function()
- local result, err = diffargs.parse_files('a.lua b.lua c.lua')
-
- assert.is_nil(result)
- assert.are.equal('expected at most two file paths', err)
- end)
-end)
diff --git a/spec/diffopt_spec.lua b/spec/diffopt_spec.lua
index 3df49963..0c8b06a8 100644
--- a/spec/diffopt_spec.lua
+++ b/spec/diffopt_spec.lua
@@ -2,7 +2,6 @@ require('spec.helpers')
local diff = require('diffs.diff')
local diffopt = require('diffs.diffopt')
-local render = require('diffs.render')
describe('diffopt resolver', function()
local saved
@@ -50,63 +49,10 @@ describe('diffopt resolver', function()
assert.are.equal('simple', diffopt.inline())
end)
- it('omits inline from git flags and vim.diff() options', function()
+ it('omits inline from vim.text.diff options', function()
vim.o.diffopt = 'internal,inline:word,iwhiteall'
- assert.are.same({ '--ignore-all-space' }, diffopt.git_flags())
assert.are.same({ ignore_whitespace = true }, diffopt.vim_diff_opts())
end)
-
- it('maps flags onto the equivalent git diff flags', function()
- vim.o.diffopt = 'internal,iwhiteall'
- assert.are.same({ '--ignore-all-space' }, diffopt.git_flags())
-
- vim.o.diffopt = 'internal,iwhite'
- assert.are.same({ '--ignore-space-change' }, diffopt.git_flags())
-
- vim.o.diffopt = 'internal,iwhiteeol'
- assert.are.same({ '--ignore-space-at-eol' }, diffopt.git_flags())
-
- vim.o.diffopt = 'internal,iblank'
- assert.are.same({ '--ignore-blank-lines' }, diffopt.git_flags())
-
- vim.o.diffopt = 'internal,algorithm:histogram'
- assert.are.same({ '--diff-algorithm=histogram' }, diffopt.git_flags())
- end)
-
- it('omits linematch from git flags (no git equivalent)', function()
- vim.o.diffopt = 'internal,linematch:60'
- assert.are.same({}, diffopt.git_flags())
- end)
-
- it('combines whitespace and algorithm git flags', function()
- vim.o.diffopt = 'internal,iwhiteall,algorithm:patience'
- local flags = diffopt.git_flags()
- assert.is_true(vim.tbl_contains(flags, '--ignore-all-space'))
- assert.is_true(vim.tbl_contains(flags, '--diff-algorithm=patience'))
- end)
-end)
-
-describe('git-backed diffs honor diffopt whitespace', function()
- local review = require('diffs.review')
- local saved
-
- before_each(function()
- saved = vim.o.diffopt
- end)
-
- after_each(function()
- vim.o.diffopt = saved
- end)
-
- it('splices whitespace flags into the review diff command', function()
- vim.o.diffopt = 'internal'
- local plain = review.build_cmd({ repo_root = '/repo', display = 'HEAD' })
- assert.is_false(vim.tbl_contains(plain, '--ignore-all-space'))
-
- vim.o.diffopt = 'internal,iwhiteall'
- local ignored = review.build_cmd({ repo_root = '/repo', display = 'HEAD' })
- assert.is_true(vim.tbl_contains(ignored, '--ignore-all-space'))
- end)
end)
describe('whitespace-only line classification', function()
@@ -202,37 +148,6 @@ describe('whitespace-only line classification', function()
end)
end)
-describe('line-level whitespace handling', function()
- local saved
-
- before_each(function()
- saved = vim.o.diffopt
- end)
-
- after_each(function()
- vim.o.diffopt = saved
- end)
-
- it('drops a whitespace-only change when iwhiteall is set', function()
- local old = { 'local x = 1', 'keep' }
- local new = { 'local x = 1 ', 'keep' }
-
- vim.o.diffopt = 'internal,filler'
- assert.is_true(#render.unified_lines(old, new, 'f', 'f') > 0)
-
- vim.o.diffopt = 'internal,filler,iwhiteall'
- assert.are.same({}, render.unified_lines(old, new, 'f', 'f'))
- end)
-
- it('keeps a content change that also touches whitespace', function()
- local old = { 'local x = 1', 'keep' }
- local new = { 'local y = 1 ', 'keep' }
-
- vim.o.diffopt = 'internal,filler,iwhiteall'
- assert.is_true(#render.unified_lines(old, new, 'f', 'f') > 0)
- end)
-end)
-
describe('intra-line whitespace handling', function()
local saved
diff --git a/spec/diffspec_spec.lua b/spec/diffspec_spec.lua
deleted file mode 100644
index bfd14949..00000000
--- a/spec/diffspec_spec.lua
+++ /dev/null
@@ -1,132 +0,0 @@
-require('spec.helpers')
-
-local diffspec = require('diffs.spec')
-
-describe('diffs.spec', function()
- local path = 'lua/foo.lua'
-
- it('represents index to worktree file diffs', function()
- local spec = diffspec.index_to_worktree(path)
-
- assert.are.same({
- left = { kind = 'index' },
- right = { kind = 'worktree' },
- scope = { kind = 'file', path = path },
- mode = 'unified',
- }, spec)
- assert.is_true(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'worktree' }, diffspec.mutability(spec))
- assert.are.same({ can_put = true, can_obtain = false }, diffspec.patch_actions(spec))
- assert.are.equal('index -> worktree file:lua/foo.lua', diffspec.label(spec))
- end)
-
- it('represents HEAD to index file diffs', function()
- local spec = diffspec.head_to_index(path)
-
- assert.are.same({
- left = { kind = 'tree', rev = 'HEAD' },
- right = { kind = 'index' },
- scope = { kind = 'file', path = path },
- mode = 'unified',
- }, spec)
- assert.is_true(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'index' }, diffspec.mutability(spec))
- assert.are.same({ can_put = false, can_obtain = true }, diffspec.patch_actions(spec))
- assert.are.equal('tree:HEAD -> index file:lua/foo.lua', diffspec.label(spec))
- end)
-
- it('represents revision to worktree file diffs', function()
- local spec = diffspec.rev_to_worktree('feature~2', path)
-
- assert.are.same({
- left = { kind = 'tree', rev = 'feature~2' },
- right = { kind = 'worktree' },
- scope = { kind = 'file', path = path },
- mode = 'unified',
- }, spec)
- assert.is_true(diffspec.is_worktree_target(spec))
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_read_only(spec))
- assert.are.same({ read_only = false, target = 'worktree' }, diffspec.mutability(spec))
- assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
- assert.are.equal('tree:feature~2 -> worktree file:lua/foo.lua', diffspec.label(spec))
- end)
-
- it('represents read-only revision to revision file diffs', function()
- local spec = diffspec.rev_to_rev('origin/main', 'HEAD', path)
-
- assert.are.same({
- left = { kind = 'tree', rev = 'origin/main' },
- right = { kind = 'tree', rev = 'HEAD' },
- scope = { kind = 'file', path = path },
- mode = 'unified',
- }, spec)
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_true(diffspec.is_read_only(spec))
- assert.are.same({ read_only = true }, diffspec.mutability(spec))
- assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
- assert.are.equal('tree:origin/main -> tree:HEAD file:lua/foo.lua', diffspec.label(spec))
- end)
-
- it('represents read-only merge-stage to worktree file diffs', function()
- local spec = diffspec.stage_to_worktree(2, path)
-
- assert.are.same({
- left = { kind = 'stage', stage = 2 },
- right = { kind = 'worktree' },
- scope = { kind = 'file', path = path },
- mode = 'unified',
- }, spec)
- assert.is_false(diffspec.is_index_target(spec))
- assert.is_false(diffspec.is_worktree_target(spec))
- assert.is_true(diffspec.is_read_only(spec))
- assert.are.same({ read_only = true }, diffspec.mutability(spec))
- assert.are.same({ can_put = false, can_obtain = false }, diffspec.patch_actions(spec))
- assert.are.equal('stage:2 -> worktree file:lua/foo.lua', diffspec.label(spec))
- end)
-
- it('rejects invalid merge-stage numbers', function()
- assert.has_error(function()
- diffspec.stage(0)
- end, 'diffs: stage endpoint must be 1, 2, or 3')
- assert.has_error(function()
- diffspec.stage(4)
- end, 'diffs: stage endpoint must be 1, 2, or 3')
- end)
-
- it('normalizes table-shaped specs without keeping caller-owned tables', function()
- local input = {
- left = { kind = 'tree', rev = 'HEAD' },
- right = { kind = 'index' },
- scope = { kind = 'file', path = path },
- }
-
- local spec = diffspec.new(input)
-
- input.left.rev = 'changed'
- input.scope.path = 'changed.lua'
-
- assert.are.equal('HEAD', spec.left.rev)
- assert.are.equal(path, spec.scope.path)
- assert.are.equal('unified', spec.mode)
- end)
-
- it('rejects unsupported endpoint and scope kinds', function()
- assert.has_error(function()
- diffspec.file({ kind = 'blob', oid = 'abc123' }, diffspec.worktree(), path)
- end, 'diffs: unsupported endpoint kind: blob')
-
- assert.has_error(function()
- diffspec.new({
- left = diffspec.index(),
- right = diffspec.worktree(),
- scope = { kind = 'repo' },
- })
- end, 'diffs: unsupported scope kind: repo')
- end)
-end)
diff --git a/spec/difftastic_spec.lua b/spec/difftastic_spec.lua
deleted file mode 100644
index 02a44c66..00000000
--- a/spec/difftastic_spec.lua
+++ /dev/null
@@ -1,219 +0,0 @@
-require('spec.helpers')
-
-local config = require('diffs.config')
-local difftastic = require('diffs.difftastic')
-local runtime = require('diffs.runtime')
-
-describe('diffs.difftastic', function()
- describe('config', function()
- it('accepts difftastic in integration lists and boolean maps', function()
- for _, integrations in ipairs({ { 'difftastic' }, { difftastic = true } }) do
- assert.is_true(config.new({ integrations = integrations }).integrations.difftastic)
- end
- end)
-
- it('accepts a table with args', function()
- assert.has_no.errors(function()
- config.new({ integrations = { difftastic = { args = { '--ignore-comments' } } } })
- end)
- end)
-
- it('rejects a non-boolean/table value', function()
- assert.has.errors(function()
- config.new({ integrations = { difftastic = 42 } })
- end)
- end)
-
- it('rejects args that is not a list', function()
- assert.has.errors(function()
- config.new({ integrations = { difftastic = { args = 'nope' } } })
- end)
- end)
-
- it('rejects args with non-string elements', function()
- assert.has.errors(function()
- config.new({ integrations = { difftastic = { args = { 123, '--flag' } } } })
- end)
- end)
-
- it('defaults to disabled when unset', function()
- local cfg = config.new({})
- assert.is_nil(cfg.integrations.difftastic)
- end)
- end)
-
- describe('resolve', function()
- local original
- before_each(function()
- original = runtime.get_difftastic_config
- end)
- after_each(function()
- runtime.get_difftastic_config = original
- end)
-
- it('is disabled for nil/false', function()
- runtime.get_difftastic_config = function()
- return nil
- end
- assert.is_false(difftastic.resolve().enabled)
- runtime.get_difftastic_config = function()
- return false
- end
- assert.is_false(difftastic.resolve().enabled)
- end)
-
- it('is enabled with empty args for true', function()
- runtime.get_difftastic_config = function()
- return true
- end
- local cfg = difftastic.resolve()
- assert.is_true(cfg.enabled)
- assert.same({}, cfg.args)
- end)
-
- it('forwards args from a table', function()
- runtime.get_difftastic_config = function()
- return { args = { '--ignore-comments' } }
- end
- assert.same({ '--ignore-comments' }, difftastic.resolve().args)
- end)
- end)
-
- describe('span_maps', function()
- it('keys changes by line_number and is order-independent', function()
- local json = {
- chunks = {
- {
- { lhs = vim.NIL, rhs = { line_number = 5, changes = { { start = 2, ['end'] = 6 } } } },
- { lhs = { line_number = 1, changes = { { start = 0, ['end'] = 3 } } }, rhs = vim.NIL },
- },
- },
- }
- local lhs, rhs = difftastic.span_maps(json)
- assert.same({ { col_start = 0, col_end = 3 } }, lhs[1])
- assert.same({ { col_start = 2, col_end = 6 } }, rhs[5])
- end)
-
- it('drops empty/zero-width spans and vim.NIL sides', function()
- local json = {
- chunks = {
- {
- { lhs = { line_number = 0, changes = { { start = 1, ['end'] = 1 } } }, rhs = vim.NIL },
- },
- },
- }
- local lhs = difftastic.span_maps(json)
- assert.same({}, lhs[0])
- end)
- end)
-
- describe('has_changes', function()
- it('is true only when a side has spans', function()
- assert.is_false(difftastic.has_changes({}, {}))
- assert.is_true(difftastic.has_changes({ [1] = { { col_start = 0, col_end = 1 } } }, {}))
- end)
- end)
-
- describe('build_alignment', function()
- -- old = {a,b,c}; new = {a,C}: a unchanged, b deleted, c rewritten to C
- local json = {
- language = 'Lua',
- status = 'changed',
- aligned_lines = { { 0, 0 }, { 1, vim.NIL }, { 2, 1 } },
- chunks = {
- {
- { lhs = { line_number = 1, changes = { { start = 0, ['end'] = 1 } } }, rhs = vim.NIL },
- {
- lhs = { line_number = 2, changes = { { start = 0, ['end'] = 1 } } },
- rhs = { line_number = 1, changes = { { start = 0, ['end'] = 1 } } },
- },
- },
- },
- }
-
- it('produces aligned rows with structural kinds', function()
- local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' })
- assert.same({ 'a', 'b', 'c' }, a.left_lines)
- assert.same({ 'a', '', 'C' }, a.right_lines)
- assert.are.equal('context', a.left_rows[1].kind)
- assert.are.equal('delete', a.left_rows[2].kind)
- assert.are.equal('filler', a.right_rows[2].kind)
- assert.are.equal('delete', a.left_rows[3].kind)
- assert.are.equal('add', a.right_rows[3].kind)
- end)
-
- it('reports changed and anchors at the first changed run', function()
- local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' })
- assert.is_true(a.changed)
- assert.same({ 2 }, a.anchors)
- end)
-
- it('carries per-row intra spans', function()
- local a = difftastic.build_alignment(json, { 'a', 'b', 'c' }, { 'a', 'C' })
- assert.same({ { col_start = 0, col_end = 1 } }, a.left_intra[3])
- assert.same({ { col_start = 0, col_end = 1 } }, a.right_intra[3])
- assert.is_nil(a.right_intra[1])
- end)
-
- it('reports not changed for a structurally identical alignment', function()
- local a = difftastic.build_alignment({
- aligned_lines = { { 0, 0 }, { 1, 1 } },
- chunks = {},
- }, { 'a', 'b' }, { 'a', 'b' })
- assert.is_false(a.changed)
- assert.same({}, a.anchors)
- end)
- end)
-
- describe('integration (difft-gated)', function()
- if vim.fn.executable('difft') ~= 1 then
- return
- end
-
- local original
- before_each(function()
- original = runtime.get_difftastic_config
- runtime.get_difftastic_config = function()
- return true
- end
- end)
- after_each(function()
- runtime.get_difftastic_config = original
- end)
-
- it('aligns a rename via real difft', function()
- local old_lines = { 'local function clamp(value, low, high)', ' return low', 'end' }
- local new_lines =
- { 'local function clamp(value, low, high)', ' return math.max(low, value)', 'end' }
- local a = difftastic.align(old_lines, new_lines, 'rate.lua')
- assert.is_truthy(a)
- assert.is_true(a.changed)
- assert.are.equal(#a.left_lines, #a.right_lines)
- end)
-
- it('reports not changed for a reindentation-only change', function()
- local old_lines = { 'local function f()', 'return 1', 'end' }
- local new_lines = { 'local function f()', ' return 1', 'end' }
- local a = difftastic.align(old_lines, new_lines, 'x.lua')
- assert.is_truthy(a)
- assert.is_false(a.changed)
- end)
-
- it('falls back cleanly on all-added and all-removed files', function()
- assert.is_nil(difftastic.align({}, { 'local x = 1', 'return x' }, 'x.lua'))
- assert.is_nil(difftastic.align({ 'local x = 1', 'return x' }, {}, 'x.lua'))
- end)
-
- it('keeps byte offsets aligned with tabs and multibyte chars', function()
- local _, rhs = difftastic.span_maps_for_content(
- { '\tlocal café_count = 1' },
- { '\tlocal café_count = 2' },
- 'x.lua'
- )
- assert.is_truthy(rhs and rhs[0])
- local line = '\tlocal café_count = 2'
- local span = rhs[0][1]
- assert.are.equal('2', line:sub(span.col_start + 1, span.col_end))
- end)
- end)
-end)
diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua
deleted file mode 100644
index b62121d3..00000000
--- a/spec/fugitive_spec.lua
+++ /dev/null
@@ -1,391 +0,0 @@
-local helpers = require('spec.helpers')
-
-local fugitive = require('diffs.fugitive')
-
-describe('fugitive', function()
- local test_buffers = {}
-
- local function create_buffer(lines)
- local buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines or {})
- test_buffers[#test_buffers + 1] = buf
- return buf
- end
-
- after_each(function()
- for _, buf in ipairs(test_buffers) do
- if vim.api.nvim_buf_is_valid(buf) then
- vim.api.nvim_buf_delete(buf, { force = true })
- end
- end
- test_buffers = {}
- end)
-
- describe('get_section_at_line', function()
- local function create_status_buffer(lines)
- return create_buffer(lines)
- end
-
- it('returns staged for lines in Staged section', function()
- local buf = create_status_buffer({
- 'Head: main',
- '',
- 'Staged (2)',
- 'M file1.lua',
- 'A file2.lua',
- '',
- 'Unstaged (1)',
- 'M file3.lua',
- })
- assert.equals('staged', fugitive.get_section_at_line(buf, 4))
- assert.equals('staged', fugitive.get_section_at_line(buf, 5))
- end)
-
- it('returns unstaged for lines in Unstaged section', function()
- local buf = create_status_buffer({
- 'Head: main',
- '',
- 'Staged (1)',
- 'M file1.lua',
- '',
- 'Unstaged (2)',
- 'M file2.lua',
- 'M file3.lua',
- })
- assert.equals('unstaged', fugitive.get_section_at_line(buf, 7))
- assert.equals('unstaged', fugitive.get_section_at_line(buf, 8))
- end)
-
- it('returns untracked for lines in Untracked section', function()
- local buf = create_status_buffer({
- 'Head: main',
- '',
- 'Untracked (2)',
- '? newfile.lua',
- '? another.lua',
- })
- assert.equals('untracked', fugitive.get_section_at_line(buf, 4))
- assert.equals('untracked', fugitive.get_section_at_line(buf, 5))
- end)
-
- it('returns nil for lines before any section', function()
- local buf = create_status_buffer({
- 'Head: main',
- 'Push: origin/main',
- '',
- 'Staged (1)',
- 'M file1.lua',
- })
- assert.is_nil(fugitive.get_section_at_line(buf, 1))
- assert.is_nil(fugitive.get_section_at_line(buf, 2))
- end)
- end)
-
- describe('get_file_at_line', function()
- local function create_status_buffer(lines)
- return create_buffer(lines)
- end
-
- it('parses simple modified file', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M src/foo.lua',
- })
- local filename, section = fugitive.get_file_at_line(buf, 2)
- assert.equals('src/foo.lua', filename)
- assert.equals('unstaged', section)
- end)
-
- it('parses renamed file and returns both names', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'R oldname.lua -> newname.lua',
- })
- local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('newname.lua', filename)
- assert.equals('staged', section)
- assert.is_false(is_header)
- assert.equals('oldname.lua', old_filename)
- end)
-
- it('parses renamed file with similarity index', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'R100 old.lua -> new.lua',
- })
- local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('new.lua', filename)
- assert.equals('staged', section)
- assert.equals('old.lua', old_filename)
- end)
-
- it('parses copied file with similarity index and returns both names', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'C100 old.lua -> copy.lua',
- })
- local filename, section, _, old_filename, status = fugitive.get_file_at_line(buf, 2)
- assert.equals('copy.lua', filename)
- assert.equals('staged', section)
- assert.equals('old.lua', old_filename)
- assert.equals('C', status)
- end)
-
- it('returns nil old_filename for non-renames', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'M modified.lua',
- })
- local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('modified.lua', filename)
- assert.equals('staged', section)
- assert.is_nil(old_filename)
- end)
-
- it('handles renamed file with spaces in name', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'R old file.lua -> new file.lua',
- })
- local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('new file.lua', filename)
- assert.equals('old file.lua', old_filename)
- end)
-
- it('unquotes git-quoted filenames with spaces', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M "path with spaces/file.lua"',
- })
- local filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('path with spaces/file.lua', filename)
- end)
-
- it('unquotes escaped quotes in filenames', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M "file\\"name.lua"',
- })
- local filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('file"name.lua', filename)
- end)
-
- it('unquotes octal escapes in filenames', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M "\\303\\251le.lua"',
- })
- local filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('\195\169le.lua', filename)
- end)
-
- it('passes through unquoted filenames unchanged', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M normal.lua',
- })
- local filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('normal.lua', filename)
- end)
-
- it('unquotes renamed files with quotes', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'R100 "old name.lua" -> "new name.lua"',
- })
- local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2)
- assert.equals('new name.lua', filename)
- assert.equals('old name.lua', old_filename)
- end)
-
- it('returns nil for section header', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- })
- local filename = fugitive.get_file_at_line(buf, 1)
- assert.is_nil(filename)
- end)
-
- it('walks back from hunk line to find file', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- })
- local filename, section = fugitive.get_file_at_line(buf, 5)
- assert.equals('file.lua', filename)
- assert.equals('unstaged', section)
- end)
-
- it('handles file with both staged and unstaged indicator', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'M both.lua',
- '',
- 'Unstaged (1)',
- 'M both.lua',
- })
- local filename1, section1 = fugitive.get_file_at_line(buf, 2)
- assert.equals('both.lua', filename1)
- assert.equals('staged', section1)
-
- local filename2, section2 = fugitive.get_file_at_line(buf, 5)
- assert.equals('both.lua', filename2)
- assert.equals('unstaged', section2)
- end)
-
- it('detects section header for Staged', function()
- local buf = create_status_buffer({
- 'Head: main',
- '',
- 'Staged (2)',
- 'M file1.lua',
- })
- local filename, section, is_header = fugitive.get_file_at_line(buf, 3)
- assert.is_nil(filename)
- assert.equals('staged', section)
- assert.is_true(is_header)
- end)
-
- it('returns is_header=false for file lines', function()
- local buf = create_status_buffer({
- 'Staged (1)',
- 'M file.lua',
- })
- local filename, section, is_header = fugitive.get_file_at_line(buf, 2)
- assert.equals('file.lua', filename)
- assert.equals('staged', section)
- assert.is_false(is_header)
- end)
- end)
-
- describe('get_hunk_position', function()
- local function create_status_buffer(lines)
- return create_buffer(lines)
- end
-
- it('returns nil when on file header line', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- })
- local pos = fugitive.get_hunk_position(buf, 2)
- assert.is_nil(pos)
- end)
-
- it('returns nil when on @@ header line', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- })
- local pos = fugitive.get_hunk_position(buf, 3)
- assert.is_nil(pos)
- end)
-
- it('returns hunk header and offset for + line', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- })
- local pos = fugitive.get_hunk_position(buf, 5)
- assert.is_not_nil(pos)
- assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
- assert.equals(2, pos.offset)
- end)
-
- it('returns hunk header and offset for context line', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- })
- local pos = fugitive.get_hunk_position(buf, 6)
- assert.is_not_nil(pos)
- assert.equals('@@ -1,3 +1,4 @@', pos.hunk_header)
- assert.equals(3, pos.offset)
- end)
-
- it('returns correct offset for first line after @@', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- })
- local pos = fugitive.get_hunk_position(buf, 4)
- assert.is_not_nil(pos)
- assert.equals(1, pos.offset)
- end)
-
- it('handles @@ header with context text', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- '@@ -10,3 +10,4 @@ function M.hello()',
- ' print("hi")',
- '+ print("world")',
- })
- local pos = fugitive.get_hunk_position(buf, 5)
- assert.is_not_nil(pos)
- assert.equals('@@ -10,3 +10,4 @@ function M.hello()', pos.hunk_header)
- assert.equals(2, pos.offset)
- end)
-
- it('returns nil when section header interrupts search', function()
- local buf = create_status_buffer({
- 'Unstaged (1)',
- 'M file.lua',
- ' some orphan line',
- })
- local pos = fugitive.get_hunk_position(buf, 3)
- assert.is_nil(pos)
- end)
- end)
-
- describe('setup_keymaps', function()
- it('installs fixed horizontal and vertical maps', function()
- local buf = create_buffer()
-
- fugitive.setup_keymaps(buf)
-
- assert.are.equal('Unified diff (horizontal)', helpers.get_keymap(buf, 'du').desc)
- assert.are.equal('Unified diff (vertical)', helpers.get_keymap(buf, 'dU').desc)
- end)
-
- it('preserves pre-existing horizontal map and installs vertical map', function()
- local buf = create_buffer()
- vim.keymap.set('n', 'du', '', { buffer = buf, desc = 'user horizontal' })
-
- fugitive.setup_keymaps(buf)
-
- assert.are.equal('user horizontal', helpers.get_keymap(buf, 'du').desc)
- assert.are.equal('Unified diff (vertical)', helpers.get_keymap(buf, 'dU').desc)
- end)
-
- it('preserves pre-existing vertical map and installs horizontal map', function()
- local buf = create_buffer()
- vim.keymap.set('n', 'dU', '', { buffer = buf, desc = 'user vertical' })
-
- fugitive.setup_keymaps(buf)
-
- assert.are.equal('Unified diff (horizontal)', helpers.get_keymap(buf, 'du').desc)
- assert.are.equal('user vertical', helpers.get_keymap(buf, 'dU').desc)
- end)
- end)
-end)
diff --git a/spec/git_spec.lua b/spec/git_spec.lua
deleted file mode 100644
index c47cba05..00000000
--- a/spec/git_spec.lua
+++ /dev/null
@@ -1,167 +0,0 @@
-require('spec.helpers')
-local git = require('diffs.git')
-
-local test_repos = {}
-
-local function git_cmd(repo_root, args)
- local cmd = { 'git', '-C', repo_root }
- for _, arg in ipairs(args) do
- cmd[#cmd + 1] = arg
- end
- local output = vim.fn.systemlist(cmd)
- assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n'))
- return output
-end
-
-local function create_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'initial' })
-
- return repo_root
-end
-
-describe('git', function()
- after_each(function()
- for _, repo_root in ipairs(test_repos) do
- vim.fn.delete(repo_root, 'rf')
- end
- test_repos = {}
- end)
-
- describe('get_repo_root', function()
- it('uses the current directory when the filepath is absent', function()
- assert.are.equal(vim.fn.getcwd(), git.get_repo_root())
- end)
-
- it('returns repo root for current repo', function()
- local cwd = vim.fn.getcwd()
- local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua')
- assert.is_not_nil(root)
- assert.are.equal(cwd, root)
- end)
-
- it('returns nil for non-git directory', function()
- local root = git.get_repo_root('/tmp')
- assert.is_nil(root)
- end)
- end)
-
- describe('get_file_content', function()
- it('returns file content at HEAD', function()
- local cwd = vim.fn.getcwd()
- local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua')
- assert.is_nil(err)
- assert.is_not_nil(content)
- assert.is_true(#content > 0)
- end)
-
- it('returns error for non-existent file', function()
- local cwd = vim.fn.getcwd()
- local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua')
- assert.is_nil(content)
- assert.is_not_nil(err)
- end)
-
- it('distinguishes a missing path from an invalid revision', function()
- local repo_root = create_repo()
- local lines, err = git.get_file_content('HEAD', repo_root .. '/missing.txt')
- assert.is_nil(lines)
- assert.are.equal('file not in revision: HEAD', err)
-
- lines, err = git.get_file_content('missing-ref', repo_root .. '/file.txt')
- assert.is_nil(lines)
- assert.are.equal('failed to resolve revision: missing-ref', err)
- end)
-
- it('reads a file from a tree expression', function()
- local repo_root = create_repo()
- local lines, err = git.get_file_content('HEAD^{tree}', repo_root .. '/file.txt')
- assert.is_nil(err)
- assert.are.same({ 'line 1', 'line 2' }, { unpack(lines) })
- end)
-
- it('reads subtree paths without confusing colon filenames', function()
- local repo_root = create_repo()
- vim.fn.mkdir(repo_root .. '/sub', 'p')
- vim.fn.writefile({ 'nested content' }, repo_root .. '/sub/file.txt')
- vim.fn.writefile({ 'different file' }, repo_root .. '/sub:file.txt')
- git_cmd(repo_root, { 'add', 'sub/file.txt', 'sub:file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'subtree' })
-
- local lines, err = git.get_file_content('HEAD:sub', repo_root .. '/file.txt')
- assert.is_nil(err)
- assert.are.same({ 'nested content' }, { unpack(lines) })
-
- lines, err = git.get_file_content('HEAD:sub', repo_root .. '/missing.txt')
- assert.is_nil(lines)
- assert.are.equal('file not in revision: HEAD:sub', err)
-
- vim.fn.writefile({ 'updated content' }, repo_root .. '/sub/file.txt')
- git_cmd(repo_root, { 'commit', '-qam', 'update subtree' })
- lines, err = git.get_file_content('HEAD:sub', repo_root .. '/file.txt')
- assert.is_nil(err)
- assert.are.same({ 'updated content' }, { unpack(lines) })
- end)
-
- it('rejects a blob expression as a file revision', function()
- local repo_root = create_repo()
- local lines, err = git.get_file_content('HEAD:file.txt', repo_root .. '/file.txt')
- assert.is_nil(lines)
- assert.are.equal('failed to resolve revision: HEAD:file.txt', err)
- end)
-
- it('returns error for non-git directory', function()
- local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt')
- assert.is_nil(content)
- assert.is_not_nil(err)
- end)
- end)
-
- describe('get_relative_path', function()
- it('returns relative path within repo', function()
- local cwd = vim.fn.getcwd()
- local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua')
- assert.are.equal('lua/diffs/init.lua', rel)
- end)
-
- it('returns nil for non-git directory', function()
- local rel = git.get_relative_path('/tmp/some_file.txt')
- assert.is_nil(rel)
- end)
- end)
-
- describe('is_unmerged', function()
- it('detects paths with unmerged index stages', function()
- local repo_root = create_repo()
- local filepath = repo_root .. '/file.txt'
-
- git_cmd(repo_root, { 'checkout', '-qb', 'ours' })
- vim.fn.writefile({ 'line 1', 'line 2 ours' }, filepath)
- git_cmd(repo_root, { 'commit', '-am', 'ours' })
- git_cmd(repo_root, { 'checkout', '-qb', 'theirs', 'HEAD~1' })
- vim.fn.writefile({ 'line 1', 'line 2 theirs' }, filepath)
- git_cmd(repo_root, { 'commit', '-am', 'theirs' })
-
- local merge_output = vim.fn.systemlist({ 'git', '-C', repo_root, 'merge', 'ours' })
- assert.are_not.equal(0, vim.v.shell_error, table.concat(merge_output, '\n'))
-
- assert.is_true(git.is_unmerged(filepath))
- end)
-
- it('returns false outside an unmerged path', function()
- local repo_root = create_repo()
-
- assert.is_false(git.is_unmerged(repo_root .. '/file.txt'))
- assert.is_false(git.is_unmerged('/tmp/not-a-diffs-repo-file.txt'))
- end)
- end)
-end)
diff --git a/spec/highlight_spec.lua b/spec/highlight_spec.lua
index 863262ce..e133b77e 100644
--- a/spec/highlight_spec.lua
+++ b/spec/highlight_spec.lua
@@ -1,7 +1,6 @@
require('spec.helpers')
local config = require('diffs.config')
local highlight = require('diffs.highlight')
-local rails = require('diffs.rails')
describe('highlight', function()
describe('hunk_opts', function()
@@ -49,7 +48,6 @@ describe('highlight', function()
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg })
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg })
- vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { fg = 0x808080, bold = true })
end)
after_each(function()
@@ -92,6 +90,7 @@ describe('highlight', function()
if overrides.highlights then
opts.highlights = vim.tbl_deep_extend('force', opts.highlights, overrides.highlights)
end
+ opts.intra = overrides.intra
if overrides.hide_prefix ~= nil then
opts.hide_prefix = overrides.hide_prefix
end
@@ -231,7 +230,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('keeps whitespace-only highlighting on difftastic-active buffers', function()
+ it('keeps line backgrounds when intra highlighting is disabled', function()
local saved = vim.o.diffopt
vim.o.diffopt = 'internal,iwhiteall'
@@ -240,7 +239,6 @@ describe('highlight', function()
'- return a + b',
'+ return a + b',
})
- vim.b[bufnr].diffs_difft_active = true
local hunk = {
filename = 'test.lua',
start_line = 1,
@@ -253,7 +251,7 @@ describe('highlight', function()
bufnr,
ns,
hunk,
- default_opts({ highlights = { background = true } })
+ default_opts({ intra = false, highlights = { background = true } })
)
vim.o.diffopt = saved
@@ -785,380 +783,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('draws generated rail change bars before line-number columns', function()
- local bufnr = create_buffer({
- ' │ @@ -1,1 +1,1 @@',
- ' 1 │ -old',
- ' 1 │ +new',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-old', '+new' },
- prefix_width = 1,
- quote_width = 10,
- rail_width = 10,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- hide_prefix = true,
- highlights = {
- background = true,
- treesitter = false,
- },
- })
- )
-
- local bars = {}
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.virt_text and d.virt_text[1] then
- local group = d.virt_text[1][2]
- if group == 'DiffsAddBar' or group == 'DiffsDeleteBar' then
- bars[mark[2]] = group
- assert.are.equal('▏', d.virt_text[1][1])
- assert.are.equal(0, mark[3])
- end
- end
- end
-
- assert.are.equal('DiffsDeleteBar', bars[1])
- assert.are.equal('DiffsAddBar', bars[2])
- delete_buffer(bufnr)
- end)
-
- it('highlights generated rail numbers', function()
- local bufnr = create_buffer({
- ' │ @@ -1,1 +1,1 @@',
- ' 1 │ -old',
- ' 1 │ +new',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-old', '+new' },
- prefix_width = 1,
- quote_width = 10,
- rail_width = 10,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- hide_prefix = true,
- highlights = {
- background = true,
- treesitter = false,
- },
- })
- )
-
- local rail_marks = {}
- local rail_numbers = {}
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.hl_group == 'DiffsRail' then
- rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- if d and d.hl_group == 'DiffsRailNr' then
- rail_numbers[#rail_numbers + 1] = { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- end
-
- assert.are.same({
- { row = 1, col = 0, end_col = 10 },
- { row = 2, col = 0, end_col = 10 },
- }, rail_marks)
- assert.are.same({
- { row = 1, col = 2, end_col = 3 },
- { row = 1, col = 4, end_col = 5 },
- { row = 2, col = 2, end_col = 3 },
- { row = 2, col = 4, end_col = 5 },
- }, rail_numbers)
- delete_buffer(bufnr)
- end)
-
- it('keeps hide_prefix overlay off generated line-number rails', function()
- local bufnr = create_buffer({
- ' │ @@ -1,1 +1,1 @@',
- ' 1 │ -old',
- ' 1 │ +new',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-old', '+new' },
- prefix_width = 1,
- quote_width = 10,
- rail_width = 10,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- hide_prefix = true,
- highlights = {
- background = true,
- treesitter = false,
- },
- })
- )
-
- local overlays = {}
- local rail_marks = {}
- local base_rail_numbers = {}
- local rail_numbers = {}
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.virt_text and d.virt_text[1] then
- local text, group = d.virt_text[1][1], d.virt_text[1][2]
- if group == 'DiffsAdd' or group == 'DiffsDelete' then
- overlays[#overlays + 1] = { row = mark[2], col = mark[3], text = text, group = group }
- end
- end
- if d and d.hl_group == 'DiffsRail' then
- rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- if d and d.hl_group == 'DiffsRailNr' then
- base_rail_numbers[#base_rail_numbers + 1] =
- { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- if d and (d.hl_group == 'DiffsAddRailNr' or d.hl_group == 'DiffsDeleteRailNr') then
- rail_numbers[#rail_numbers + 1] =
- { row = mark[2], col = mark[3], end_col = d.end_col, group = d.hl_group }
- end
- end
-
- assert.are.same({
- { row = 1, col = 10, text = ' ', group = 'DiffsDelete' },
- { row = 2, col = 10, text = ' ', group = 'DiffsAdd' },
- }, overlays)
- assert.are.same({
- { row = 1, col = 0, end_col = 10 },
- { row = 2, col = 0, end_col = 10 },
- }, rail_marks)
- assert.are.same({
- { row = 1, col = 2, end_col = 3 },
- { row = 1, col = 4, end_col = 5 },
- { row = 2, col = 2, end_col = 3 },
- { row = 2, col = 4, end_col = 5 },
- }, base_rail_numbers)
- assert.are.same({
- { row = 1, col = 2, end_col = 5, group = 'DiffsDeleteRailNr' },
- { row = 2, col = 2, end_col = 5, group = 'DiffsAddRailNr' },
- }, rail_numbers)
- delete_buffer(bufnr)
- end)
-
- it('highlights single generated rail numbers for context and changed lines', function()
- local annotated, info = rails.annotate({
- '@@ -1,2 +1,2 @@',
- ' local M = {}',
- '-local old = false',
- '+local new = true',
- }, { rail_style = 'single' })
- local bufnr = create_buffer(annotated)
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = {
- ' local M = {}',
- '-local old = false',
- '+local new = true',
- },
- prefix_width = 1,
- quote_width = info.prefix_width,
- rail_width = info.prefix_width,
- rail_style = info.style,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- highlights = {
- treesitter = false,
- },
- })
- )
-
- local rail_marks = {}
- local base_numbers = {}
- local changed_numbers = {}
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.hl_group == 'DiffsRail' then
- rail_marks[#rail_marks + 1] = { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- if d and d.hl_group == 'DiffsRailNr' then
- base_numbers[#base_numbers + 1] = { row = mark[2], col = mark[3], end_col = d.end_col }
- end
- if d and (d.hl_group == 'DiffsAddRailNr' or d.hl_group == 'DiffsDeleteRailNr') then
- changed_numbers[#changed_numbers + 1] =
- { row = mark[2], col = mark[3], end_col = d.end_col, group = d.hl_group }
- end
- end
-
- assert.are.same({
- { row = 1, col = 0, end_col = info.prefix_width },
- { row = 2, col = 0, end_col = info.prefix_width },
- { row = 3, col = 0, end_col = info.prefix_width },
- }, rail_marks)
- assert.are.same({
- { row = 1, col = 2, end_col = 3 },
- { row = 2, col = 2, end_col = 3 },
- { row = 3, col = 2, end_col = 3 },
- }, base_numbers)
- assert.are.same({
- { row = 2, col = 2, end_col = 3, group = 'DiffsDeleteRailNr' },
- { row = 3, col = 2, end_col = 3, group = 'DiffsAddRailNr' },
- }, changed_numbers)
- delete_buffer(bufnr)
- end)
-
- it('keeps hide_prefix overlay off single generated line-number rails', function()
- local annotated, info = rails.annotate({
- '@@ -1,1 +1,1 @@',
- '-old',
- '+new',
- }, { rail_style = 'single' })
- local bufnr = create_buffer(annotated)
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '-old', '+new' },
- prefix_width = 1,
- quote_width = info.prefix_width,
- rail_width = info.prefix_width,
- rail_style = info.style,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- hide_prefix = true,
- highlights = {
- background = true,
- treesitter = false,
- },
- })
- )
-
- local overlays = {}
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.virt_text and d.virt_text[1] then
- local text, group = d.virt_text[1][1], d.virt_text[1][2]
- if group == 'DiffsAdd' or group == 'DiffsDelete' then
- overlays[#overlays + 1] = { row = mark[2], col = mark[3], text = text, group = group }
- assert.is_true(mark[3] >= info.prefix_width)
- end
- end
- end
-
- assert.are.same({
- { row = 1, col = info.prefix_width, text = ' ', group = 'DiffsDelete' },
- { row = 2, col = info.prefix_width, text = ' ', group = 'DiffsAdd' },
- }, overlays)
- delete_buffer(bufnr)
- end)
-
- it('leaves generated context prefix cells unpainted for cursorline', function()
- local bufnr = create_buffer({
- ' 1 1 │ local M = {}',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 0,
- lines = { ' local M = {}' },
- prefix_width = 1,
- quote_width = 10,
- rail_width = 10,
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({
- hide_prefix = true,
- highlights = {
- background = true,
- treesitter = false,
- },
- })
- )
-
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if d and d.virt_text_pos == 'overlay' then
- error('unexpected hide_prefix overlay on generated context line')
- end
- if d and d.hl_group == 'DiffsClear' then
- assert.are.equal(0, mark[3])
- assert.are.equal(10, d.end_col)
- end
- end
-
- delete_buffer(bufnr)
- end)
-
- it('does not apply DiffsClear over generated rail source content', function()
- local bufnr = create_buffer({
- ' │ @@ -0,0 +1,1 @@',
- ' 1 │ +local untracked = {}',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { '+local untracked = {}' },
- prefix_width = 1,
- quote_width = 10,
- rail_width = 10,
- }
-
- highlight.highlight_hunk(bufnr, ns, hunk, default_opts())
-
- local has_keyword = false
- for _, mark in ipairs(get_extmarks(bufnr)) do
- local d = mark[4]
- if mark[2] == 1 and d and d.hl_group == '@keyword.lua' then
- has_keyword = true
- end
- if mark[2] == 1 and d and d.hl_group == 'DiffsClear' then
- assert.is_true(mark[3] < 11, 'DiffsClear starts on generated content at col ' .. mark[3])
- end
- end
-
- assert.is_true(has_keyword, 'treesitter keyword highlight was applied')
- delete_buffer(bufnr)
- end)
-
it('does not apply prefix extmark on context line', function()
local bufnr = create_buffer({
'@@ -1,2 +1,2 @@',
@@ -1808,105 +1432,6 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
- it('applies DiffsConflictMarker text on markers with DiffsAdd bg', function()
- local bufnr = create_buffer({
- '@@@ -1,5 -1,5 +1,9 @@@',
- ' local M = {}',
- '++<<<<<<< HEAD',
- '+ local x = 1',
- '++||||||| base',
- '++=======',
- ' +local y = 2',
- '++>>>>>>> feature',
- ' return M',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- prefix_width = 2,
- lines = {
- ' local M = {}',
- '++<<<<<<< HEAD',
- '+ local x = 1',
- '++||||||| base',
- '++=======',
- ' +local y = 2',
- '++>>>>>>> feature',
- ' return M',
- },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { background = true } })
- )
-
- local extmarks = get_extmarks(bufnr)
- local line_bgs = {}
- local marker_text = {}
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
- line_bgs[mark[2]] = d.hl_group
- end
- if d and d.hl_group == 'DiffsConflictMarker' then
- marker_text[mark[2]] = true
- end
- end
-
- assert.is_nil(line_bgs[1])
- assert.are.equal('DiffsAdd', line_bgs[2])
- assert.are.equal('DiffsAdd', line_bgs[3])
- assert.are.equal('DiffsAdd', line_bgs[4])
- assert.are.equal('DiffsAdd', line_bgs[5])
- assert.are.equal('DiffsAdd', line_bgs[6])
- assert.are.equal('DiffsAdd', line_bgs[7])
- assert.is_nil(line_bgs[8])
-
- assert.is_true(marker_text[2] ~= nil)
- assert.is_nil(marker_text[3])
- assert.is_true(marker_text[4] ~= nil)
- assert.is_true(marker_text[5] ~= nil)
- assert.is_nil(marker_text[6])
- assert.is_true(marker_text[7] ~= nil)
- delete_buffer(bufnr)
- end)
-
- it('does not apply DiffsConflictMarker in unified diffs', function()
- local bufnr = create_buffer({
- '@@ -1,1 +1,4 @@',
- ' local M = {}',
- '+<<<<<<< HEAD',
- '+local x = 1',
- '+=======',
- })
-
- local hunk = {
- filename = 'test.lua',
- lang = 'lua',
- start_line = 1,
- lines = { ' local M = {}', '+<<<<<<< HEAD', '+local x = 1', '+=======' },
- }
-
- highlight.highlight_hunk(
- bufnr,
- ns,
- hunk,
- default_opts({ highlights = { background = true } })
- )
-
- local extmarks = get_extmarks(bufnr)
- for _, mark in ipairs(extmarks) do
- local d = mark[4]
- assert.is_not_equal('DiffsConflictMarker', d and d.hl_group)
- end
- delete_buffer(bufnr)
- end)
-
it('filters @spell and @nospell captures from injections', function()
local bufnr = create_buffer({
'@@ -1,1 +1,2 @@',
diff --git a/spec/hunks_spec.lua b/spec/hunks_spec.lua
deleted file mode 100644
index 7005ed80..00000000
--- a/spec/hunks_spec.lua
+++ /dev/null
@@ -1,383 +0,0 @@
-local helpers = require('spec.helpers')
-
-local diffspec = require('diffs.spec')
-local hunks = require('diffs.hunks')
-
-local function sample_lines()
- return {
- 'diff --git a/lua/foo.lua b/lua/foo.lua',
- '--- a/lua/foo.lua',
- '+++ b/lua/foo.lua',
- '@@ -1,3 +1,4 @@',
- ' local a = 1',
- '-local b = 2',
- '+local b = 3',
- ' local c = 4',
- '+local d = 5',
- }
-end
-
-describe('diffs.hunks', function()
- describe('parse', function()
- it('builds hunk and line metadata for added, deleted, and context lines', function()
- local lines = sample_lines()
- lines[6] = '--- removed'
- lines[7] = '+++ added'
- local parsed = hunks.parse(lines, diffspec.index_to_worktree('lua/foo.lua'))
-
- assert.are.equal(1, #parsed)
- assert.are.equal('lua/foo.lua', parsed[1].file)
- assert.are.same({ start = 1, count = 3, finish = 3 }, parsed[1].old_range)
- assert.are.same({ start = 1, count = 4, finish = 4 }, parsed[1].new_range)
- assert.are.same({ start = 4, count = 6, finish = 9 }, parsed[1].buffer_range)
- assert.are.same({ start = 1, count = 3, finish = 3 }, parsed[1].file_header_range)
- assert.are.same({
- 'diff --git a/lua/foo.lua b/lua/foo.lua',
- '--- a/lua/foo.lua',
- '+++ b/lua/foo.lua',
- }, parsed[1].file_header_lines)
- assert.are.equal('@@ -1,3 +1,4 @@', parsed[1].header)
- assert.is_true(parsed[1].can_put)
- assert.is_false(parsed[1].can_obtain)
- assert.is_true(parsed[1].actionable)
- assert.are.equal('worktree', parsed[1].mutation_target)
- assert.are.same({ kind = 'index' }, parsed[1].edge.left)
- assert.are.same({ kind = 'worktree' }, parsed[1].edge.right)
-
- assert.are.equal('header', parsed[1].lines[1].kind)
- assert.are.equal(4, parsed[1].lines[1].lnum)
- assert.are.equal(1, parsed[1].lines[1].old_lnum)
- assert.are.equal(1, parsed[1].lines[1].new_lnum)
-
- assert.are.equal('context', parsed[1].lines[2].kind)
- assert.are.equal(1, parsed[1].lines[2].old_lnum)
- assert.are.equal(1, parsed[1].lines[2].new_lnum)
-
- assert.are.equal('delete', parsed[1].lines[3].kind)
- assert.are.equal(2, parsed[1].lines[3].old_lnum)
- assert.is_nil(parsed[1].lines[3].new_lnum)
- assert.are.equal(2, parsed[1].lines[3].source_lnum)
-
- assert.are.equal('add', parsed[1].lines[4].kind)
- assert.is_nil(parsed[1].lines[4].old_lnum)
- assert.are.equal(2, parsed[1].lines[4].new_lnum)
-
- assert.are.equal('add', parsed[1].lines[6].kind)
- assert.are.equal(4, parsed[1].lines[6].new_lnum)
- end)
-
- it('keeps no-newline markers inside the hunk as metadata lines', function()
- local parsed = hunks.parse({
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1 +1 @@',
- '-return 1',
- '\\ No newline at end of file',
- '+return 2',
- '\\ No newline at end of file',
- }, diffspec.index_to_worktree('foo.lua'))
-
- assert.are.same({ start = 4, count = 5, finish = 8 }, parsed[1].buffer_range)
- assert.are.equal('meta', parsed[1].lines[3].kind)
- assert.are.equal(6, parsed[1].lines[3].lnum)
- assert.are.equal('meta', parsed[1].lines[5].kind)
- assert.are.equal(8, parsed[1].lines[5].lnum)
- end)
-
- it('detects multiple hunks in one file', function()
- local parsed = hunks.parse({
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1 +1 @@',
- '-one',
- '+two',
- '@@ -10 +10 @@',
- '-ten',
- '+eleven',
- }, diffspec.index_to_worktree('foo.lua'))
-
- assert.are.equal(2, #parsed)
- assert.are.equal('foo.lua', parsed[1].file)
- assert.are.equal('foo.lua', parsed[2].file)
- assert.are.same({ start = 4, count = 3, finish = 6 }, parsed[1].buffer_range)
- assert.are.same({ start = 7, count = 3, finish = 9 }, parsed[2].buffer_range)
- assert.are.same({ start = 10, count = 1, finish = 10 }, parsed[2].old_range)
- assert.are.same({ start = 10, count = 1, finish = 10 }, parsed[2].new_range)
- assert.are.same(parsed[1].file_header_lines, parsed[2].file_header_lines)
- end)
-
- it('detects hunks across multiple files', function()
- local parsed = hunks.parse({
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1 +1 @@',
- '-one',
- '+two',
- 'diff --git a/bar.py b/bar.py',
- '--- a/bar.py',
- '+++ b/bar.py',
- '@@ -3 +3 @@',
- '-old',
- '+new',
- })
-
- assert.are.equal(2, #parsed)
- assert.are.equal('foo.lua', parsed[1].file)
- assert.are.equal('bar.py', parsed[2].file)
- assert.are.same({ start = 4, count = 3, finish = 6 }, parsed[1].buffer_range)
- assert.are.same({ start = 10, count = 3, finish = 12 }, parsed[2].buffer_range)
- end)
-
- it('preserves real top-level a and b directory names', function()
- local parsed = hunks.parse({
- 'diff --git a/a/foo.lua b/a/foo.lua',
- '--- a/a/foo.lua',
- '+++ b/a/foo.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- 'diff --git a/b/bar.lua b/b/bar.lua',
- '--- a/b/bar.lua',
- '+++ b/b/bar.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- })
-
- assert.are.equal('a/foo.lua', parsed[1].file)
- assert.are.equal('b/bar.lua', parsed[2].file)
- end)
-
- it('marks tree to tree hunks as read-only', function()
- local parsed =
- hunks.parse(sample_lines(), diffspec.rev_to_rev('HEAD~1', 'HEAD', 'lua/foo.lua'))
-
- assert.is_false(parsed[1].can_put)
- assert.is_false(parsed[1].can_obtain)
- assert.is_false(parsed[1].actionable)
- assert.is_nil(parsed[1].mutation_target)
- assert.are.same({ kind = 'tree', rev = 'HEAD~1' }, parsed[1].edge.left)
- assert.are.same({ kind = 'tree', rev = 'HEAD' }, parsed[1].edge.right)
- end)
-
- it('keeps right-endpoint mutation metadata separate from patch actionability', function()
- local worktree_parsed =
- hunks.parse(sample_lines(), diffspec.rev_to_worktree('HEAD', 'lua/foo.lua'))
- local index_parsed = hunks.parse(sample_lines(), diffspec.head_to_index('lua/foo.lua'))
-
- assert.are.equal('worktree', worktree_parsed[1].mutation_target)
- assert.is_false(worktree_parsed[1].can_put)
- assert.is_false(worktree_parsed[1].can_obtain)
- assert.is_false(worktree_parsed[1].actionable)
-
- assert.are.equal('index', index_parsed[1].mutation_target)
- assert.is_false(index_parsed[1].can_put)
- assert.is_true(index_parsed[1].can_obtain)
- assert.is_true(index_parsed[1].actionable)
- end)
- end)
-
- describe('lookup helpers', function()
- it('resolves cursor lines to hunks and hunk lines', function()
- local parsed = hunks.parse(sample_lines(), diffspec.index_to_worktree('lua/foo.lua'))
-
- assert.is_nil(hunks.hunk_at_line(parsed, 1))
- assert.are.equal(parsed[1], hunks.hunk_at_line(parsed, 4))
- assert.are.equal(parsed[1], hunks.hunk_at_line(parsed, 6))
- assert.are.equal('header', hunks.line_at(parsed, 4).kind)
- assert.are.equal('delete', hunks.line_at(parsed, 6).kind)
- assert.are.equal('context', hunks.line_at(parsed, 8).kind)
- assert.is_nil(hunks.line_at(parsed, 2))
- end)
-
- it('finds next and previous hunk boundaries', function()
- local parsed = hunks.parse({
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1 +1 @@',
- '-one',
- '+two',
- '@@ -10 +10 @@',
- '-ten',
- '+eleven',
- }, diffspec.index_to_worktree('foo.lua'))
-
- assert.are.equal(parsed[1], hunks.next_hunk(parsed, 1))
- assert.are.equal(parsed[2], hunks.next_hunk(parsed, 4))
- assert.is_nil(hunks.next_hunk(parsed, 7))
- assert.are.equal(parsed[1], hunks.prev_hunk(parsed, 6))
- assert.are.equal(parsed[1], hunks.prev_hunk(parsed, 7))
- assert.is_nil(hunks.prev_hunk(parsed, 4))
- end)
-
- it('maps hunks and lines to source file positions', function()
- local parsed = hunks.parse(sample_lines(), diffspec.index_to_worktree('lua/foo.lua'))
-
- assert.are.same({ path = 'lua/foo.lua', lnum = 1 }, hunks.source_line_for(parsed[1]))
- assert.are.same({ path = 'lua/foo.lua', lnum = 2 }, hunks.source_line_for(parsed[1].lines[3]))
- assert.are.same({ path = 'lua/foo.lua', lnum = 4 }, hunks.source_line_for(parsed[1].lines[6]))
- end)
- end)
-
- describe('navigation', function()
- local bufnr
-
- after_each(function()
- if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- bufnr = nil
- end)
-
- it('jumps between stored hunk boundaries with wrapping', function()
- local lines = {
- 'diff --git a/foo.lua b/foo.lua',
- '--- a/foo.lua',
- '+++ b/foo.lua',
- '@@ -1 +1 @@',
- '-one',
- '+two',
- '@@ -10 +10 @@',
- '-ten',
- '+eleven',
- }
- local parsed = hunks.parse(lines, diffspec.index_to_worktree('foo.lua'))
- bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_hunks', parsed)
- vim.api.nvim_set_current_buf(bufnr)
-
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
- hunks.goto_next(bufnr)
- assert.are.same({ 4, 0 }, vim.api.nvim_win_get_cursor(0))
-
- hunks.goto_next(bufnr)
- assert.are.same({ 7, 0 }, vim.api.nvim_win_get_cursor(0))
-
- hunks.goto_prev(bufnr)
- assert.are.same({ 4, 0 }, vim.api.nvim_win_get_cursor(0))
- end)
- end)
-
- describe('source opening', function()
- local source_buf
- local diff_buf
- local source_win
- local diff_win
- local saved_notify
-
- local function cleanup()
- if saved_notify then
- vim.notify = saved_notify
- saved_notify = nil
- end
- pcall(vim.cmd, 'only')
- helpers.delete_buffer(diff_buf)
- helpers.delete_buffer(source_buf)
- source_buf = nil
- diff_buf = nil
- source_win = nil
- diff_win = nil
- end
-
- local function create_source_window()
- source_buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(source_buf, '/tmp/repo/lua/foo.lua')
- vim.api.nvim_buf_set_lines(source_buf, 0, -1, false, {
- 'local a = 1',
- 'local b = 3',
- 'local c = 4',
- 'local d = 5',
- })
- vim.api.nvim_set_current_buf(source_buf)
- source_win = vim.api.nvim_get_current_win()
- end
-
- local function create_diff_window(diff_spec)
- local lines = sample_lines()
- diff_buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, lines)
- vim.api.nvim_buf_set_var(diff_buf, 'diffs_hunks', hunks.parse(lines, diff_spec))
- vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = '/tmp/repo' })
- vim.cmd('split')
- diff_win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(diff_win, diff_buf)
- end
-
- after_each(cleanup)
-
- it('opens an existing worktree source window from an added line', function()
- create_source_window()
- create_diff_window(diffspec.index_to_worktree('lua/foo.lua'))
-
- vim.api.nvim_win_set_cursor(diff_win, { 7, 0 })
-
- assert.is_true(hunks.open_source(diff_buf))
- assert.are.equal(source_win, vim.api.nvim_get_current_win())
- assert.are.equal(source_buf, vim.api.nvim_get_current_buf())
- assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0))
- end)
-
- it('maps deleted lines to the best worktree source line', function()
- create_source_window()
- create_diff_window(diffspec.rev_to_worktree('HEAD', 'lua/foo.lua'))
-
- vim.api.nvim_win_set_cursor(diff_win, { 6, 0 })
-
- assert.is_true(hunks.open_source(diff_buf))
- assert.are.equal(source_win, vim.api.nvim_get_current_win())
- assert.are.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0))
- end)
-
- it('refuses index-backed hunks with a clear message', function()
- local notification
- saved_notify = vim.notify
- vim.notify = function(message, level)
- notification = { message = message, level = level }
- end
- create_source_window()
- create_diff_window(diffspec.head_to_index('lua/foo.lua'))
-
- vim.api.nvim_win_set_cursor(diff_win, { 7, 0 })
-
- assert.is_false(hunks.open_source(diff_buf))
- assert.are.equal(diff_win, vim.api.nvim_get_current_win())
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(notification.message:find('index%-backed diff hunk') ~= nil)
- end)
-
- it('refuses read-only tree-backed hunks with a clear message', function()
- local notification
- saved_notify = vim.notify
- vim.notify = function(message, level)
- notification = { message = message, level = level }
- end
- create_source_window()
- create_diff_window(diffspec.rev_to_rev('HEAD~1', 'HEAD', 'lua/foo.lua'))
-
- vim.api.nvim_win_set_cursor(diff_win, { 7, 0 })
-
- assert.is_false(hunks.open_source(diff_buf))
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(notification.message:find('read%-only tree%-backed diff hunk') ~= nil)
- end)
-
- it('returns the resolved source location without opening it', function()
- create_source_window()
- create_diff_window(diffspec.index_to_worktree('lua/foo.lua'))
-
- vim.api.nvim_win_set_cursor(diff_win, { 9, 0 })
-
- assert.are.same(
- { path = '/tmp/repo/lua/foo.lua', lnum = 4 },
- hunks.source_at_cursor(diff_buf)
- )
- end)
- end)
-end)
diff --git a/spec/integration_spec.lua b/spec/integration_spec.lua
index 1abeaf94..33284dbb 100644
--- a/spec/integration_spec.lua
+++ b/spec/integration_spec.lua
@@ -164,7 +164,7 @@ describe('integration', function()
local entry = runtime._test.hunk_cache[bufnr]
assert.is_not_nil(entry)
assert.is_nil(entry.hunks[1].ft)
- assert.is_true(runtime._test.ft_retry_pending[bufnr] == true)
+ assert.is_not_nil(runtime._test.ft_retry_pending[bufnr])
delete_buffer(bufnr)
end)
@@ -177,7 +177,7 @@ describe('integration', function()
'+ listen 8080;',
})
runtime.attach(bufnr)
- assert.is_true(runtime._test.ft_retry_pending[bufnr] == true)
+ assert.is_not_nil(runtime._test.ft_retry_pending[bufnr])
run_scheduled_callbacks()
@@ -185,6 +185,29 @@ describe('integration', function()
delete_buffer(bufnr)
end)
+ it('does not refresh a new attachment from an old filetype retry', function()
+ local bufnr = create_buffer({
+ 'diff --git a/app.conf b/app.conf',
+ '@@ -1 +1 @@',
+ '-old',
+ '+new',
+ })
+ runtime.attach(bufnr)
+ assert.is_not_nil(runtime._test.ft_retry_pending[bufnr])
+ runtime.detach(bufnr)
+ local calls = 0
+ runtime.attach(bufnr, {
+ hunks = function()
+ calls = calls + 1
+ return {}
+ end,
+ })
+ run_scheduled_callbacks()
+ assert.are.equal(1, calls)
+ assert.is_nil(runtime._test.ft_retry_pending[bufnr])
+ delete_buffer(bufnr)
+ end)
+
it('refreshes cache after scheduled callback fires', function()
local bufnr = create_buffer({
'diff --git a/app.conf b/app.conf',
diff --git a/spec/jj_conflict_spec.lua b/spec/jj_conflict_spec.lua
deleted file mode 100644
index b1b60007..00000000
--- a/spec/jj_conflict_spec.lua
+++ /dev/null
@@ -1,163 +0,0 @@
-local conflict = require('diffs.conflict')
-
-local function create_file_buffer(lines)
- local bufnr = vim.api.nvim_create_buf(false, false)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
- return bufnr
-end
-
-local function snapshot_lines()
- return {
- 'line1',
- '<<<<<<< conflict 1 of 1',
- '+++++++ tzmtwuuv 321864a5 "sidea" (rebase destination)',
- 'AAA',
- '------- sozvolrq 24563c2b "base" (parents of rebased revision)',
- 'line2',
- '+++++++ vooloppp 8bb449c1 "sideb" (rebased revision)',
- 'BBB',
- '>>>>>>> conflict 1 of 1 ends',
- 'line3',
- }
-end
-
-local function diff_lines()
- return {
- 'line1',
- '<<<<<<< conflict 1 of 1',
- '%%%%%%% diff from: sozvolrq 24563c2b "base" (parents of rebased revision)',
- '\\\\\\\\\\\\\\ to: tzmtwuuv 321864a5 "sidea" (rebase destination)',
- '-line2',
- '+AAA',
- '+++++++ vooloppp 8bb449c1 "sideb" (rebased revision)',
- 'BBB',
- '>>>>>>> conflict 1 of 1 ends',
- 'line3',
- }
-end
-
-local function three_sided_lines()
- return {
- 'line1',
- '<<<<<<< conflict 1 of 1',
- '+++++++ sideA',
- 'AAA',
- '------- base',
- 'line2',
- '+++++++ sideB',
- 'BBB',
- '------- base',
- 'line2',
- '+++++++ sideC',
- 'CCC',
- '>>>>>>> conflict 1 of 1 ends',
- 'line3',
- }
-end
-
-describe('jj conflict markers', function()
- describe('snapshot style', function()
- it('parses a two sided conflict', function()
- local regions = conflict.parse(snapshot_lines())
- assert.are.equal(1, #regions)
- local region = regions[1]
- assert.are.equal(1, region.marker_open)
- assert.are.equal(2, region.marker_ours)
- assert.are.equal(3, region.ours_start)
- assert.are.equal(4, region.ours_end)
- assert.are.equal(4, region.marker_base)
- assert.are.equal(5, region.base_start)
- assert.are.equal(6, region.base_end)
- assert.are.equal(6, region.marker_sep)
- assert.are.equal(7, region.theirs_start)
- assert.are.equal(8, region.theirs_end)
- assert.are.equal(8, region.marker_theirs)
- end)
-
- it('resolves to the first side', function()
- local bufnr = create_file_buffer(snapshot_lines())
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 4, 0 })
- conflict.resolve_ours(bufnr)
- assert.are.same({ 'line1', 'AAA', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('resolves to the last side', function()
- local bufnr = create_file_buffer(snapshot_lines())
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 4, 0 })
- conflict.resolve_theirs(bufnr)
- assert.are.same({ 'line1', 'BBB', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('removes the opening marker when resolving', function()
- local bufnr = create_file_buffer(snapshot_lines())
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
- conflict.resolve_none(bufnr)
- assert.are.same({ 'line1', 'line3' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('highlights the opening marker', function()
- local bufnr = create_file_buffer(snapshot_lines())
- conflict.attach(bufnr)
- local rows = {}
- for _, mark in
- ipairs(
- vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, { details = true })
- )
- do
- if mark[4] and mark[4].hl_group == 'DiffsConflictMarker' then
- rows[mark[2]] = true
- end
- end
- assert.is_true(rows[1])
- assert.is_true(rows[2])
- assert.is_true(rows[4])
- assert.is_true(rows[6])
- assert.is_true(rows[8])
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
- end)
-
- describe('unsupported styles', function()
- it('does not parse diff style', function()
- assert.are.equal(0, #conflict.parse(diff_lines()))
- end)
-
- it('does not parse conflicts with more than two sides', function()
- assert.are.equal(0, #conflict.parse(three_sided_lines()))
- end)
-
- it('does not attach when nothing parses', function()
- local bufnr = create_file_buffer(diff_lines())
- conflict.attach(bufnr)
- assert.are.equal(
- 0,
- #vim.api.nvim_buf_get_extmarks(bufnr, conflict.get_namespace(), 0, -1, {})
- )
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
- end)
-
- describe('git style regression', function()
- it('still parses without an opening marker field', function()
- local regions = conflict.parse({
- '<<<<<<< tzmtwuuv 321864a5 "sidea" (rebase destination)',
- 'AAA',
- '||||||| sozvolrq 24563c2b "base" (parents of rebased revision)',
- 'line2',
- '=======',
- 'BBB',
- '>>>>>>> vooloppp 8bb449c1 "sideb" (rebased revision)',
- })
- assert.are.equal(1, #regions)
- assert.is_nil(regions[1].marker_open)
- assert.are.equal(0, regions[1].marker_ours)
- assert.are.equal(6, regions[1].marker_theirs)
- end)
- end)
-end)
diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua
deleted file mode 100644
index a302ce3c..00000000
--- a/spec/merge_spec.lua
+++ /dev/null
@@ -1,792 +0,0 @@
-local helpers = require('spec.helpers')
-local merge = require('diffs.merge')
-local rails = require('diffs.rails')
-
-local function create_diff_buffer(lines, working_path)
- local bufnr = helpers.create_buffer(lines)
- if working_path then
- vim.api.nvim_buf_set_var(bufnr, 'diffs_source', { working_path = working_path })
- end
- return bufnr
-end
-
-local function create_working_buffer(lines, name)
- local bufnr = vim.api.nvim_create_buf(true, false)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- if name then
- vim.api.nvim_buf_set_name(bufnr, name)
- end
- return bufnr
-end
-
-describe('merge', function()
- describe('parse_hunks', function()
- it('parses a single hunk', function()
- local bufnr = helpers.create_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,3 @@',
- ' local M = {}',
- '-local x = 1',
- '+local x = 2',
- ' return M',
- })
-
- local hunks = merge.parse_hunks(bufnr)
- assert.are.equal(1, #hunks)
- assert.are.equal(3, hunks[1].start_line)
- assert.are.equal(7, hunks[1].end_line)
- assert.are.same({ 'local x = 1' }, hunks[1].del_lines)
- assert.are.same({ 'local x = 2' }, hunks[1].add_lines)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('parses generated hunks with line-number rails', function()
- local annotated, info = rails.annotate({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,3 @@',
- ' local M = {}',
- '-local x = 1',
- '+local x = 2',
- ' return M',
- })
- local bufnr = helpers.create_buffer(annotated)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width)
-
- local hunks = merge.parse_hunks(bufnr)
-
- assert.are.equal(1, #hunks)
- assert.are.equal(3, hunks[1].start_line)
- assert.are.equal(7, hunks[1].end_line)
- assert.are.same({ 'local x = 1' }, hunks[1].del_lines)
- assert.are.same({ 'local x = 2' }, hunks[1].add_lines)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('parses multiple hunks', function()
- local bufnr = helpers.create_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,3 @@',
- ' local M = {}',
- '-local x = 1',
- '+local x = 2',
- ' return M',
- '@@ -10,3 +10,3 @@',
- ' function M.foo()',
- '- return 1',
- '+ return 2',
- ' end',
- })
-
- local hunks = merge.parse_hunks(bufnr)
- assert.are.equal(2, #hunks)
- assert.are.equal(3, hunks[1].start_line)
- assert.are.equal(8, hunks[2].start_line)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('parses add-only hunk', function()
- local bufnr = helpers.create_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,2 +1,3 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- })
-
- local hunks = merge.parse_hunks(bufnr)
- assert.are.equal(1, #hunks)
- assert.are.same({}, hunks[1].del_lines)
- assert.are.same({ 'local new = true' }, hunks[1].add_lines)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('parses delete-only hunk', function()
- local bufnr = helpers.create_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,3 +1,2 @@',
- ' local M = {}',
- '-local old = false',
- ' return M',
- })
-
- local hunks = merge.parse_hunks(bufnr)
- assert.are.equal(1, #hunks)
- assert.are.same({ 'local old = false' }, hunks[1].del_lines)
- assert.are.same({}, hunks[1].add_lines)
-
- helpers.delete_buffer(bufnr)
- end)
-
- it('returns empty for buffer with no hunks', function()
- local bufnr = helpers.create_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- })
-
- local hunks = merge.parse_hunks(bufnr)
- assert.are.equal(0, #hunks)
-
- helpers.delete_buffer(bufnr)
- end)
- end)
-
- describe('match_hunk_to_conflict', function()
- it('matches hunk to conflict region', function()
- local working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, '/tmp/diffs_test_match.lua')
-
- local hunk = {
- index = 1,
- start_line = 3,
- end_line = 7,
- del_lines = { 'local x = 1' },
- add_lines = { 'local x = 2' },
- }
-
- local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
- assert.is_not_nil(region)
- assert.are.equal(0, region.marker_ours)
-
- helpers.delete_buffer(working_bufnr)
- end)
-
- it('returns nil for auto-merged content', function()
- local working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, '/tmp/diffs_test_auto.lua')
-
- local hunk = {
- index = 1,
- start_line = 3,
- end_line = 7,
- del_lines = { 'local y = 3' },
- add_lines = { 'local y = 4' },
- }
-
- local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
- assert.is_nil(region)
-
- helpers.delete_buffer(working_bufnr)
- end)
-
- it('matches with empty ours section', function()
- local working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, '/tmp/diffs_test_empty_ours.lua')
-
- local hunk = {
- index = 1,
- start_line = 3,
- end_line = 5,
- del_lines = {},
- add_lines = { 'local x = 2' },
- }
-
- local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
- assert.is_not_nil(region)
-
- helpers.delete_buffer(working_bufnr)
- end)
-
- it('matches correct region among multiple conflicts', function()
- local working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local a = 1',
- '=======',
- 'local a = 2',
- '>>>>>>> feature',
- 'middle',
- '<<<<<<< HEAD',
- 'local b = 3',
- '=======',
- 'local b = 4',
- '>>>>>>> feature',
- }, '/tmp/diffs_test_multi.lua')
-
- local hunk = {
- index = 2,
- start_line = 8,
- end_line = 12,
- del_lines = { 'local b = 3' },
- add_lines = { 'local b = 4' },
- }
-
- local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
- assert.is_not_nil(region)
- assert.are.equal(6, region.marker_ours)
-
- helpers.delete_buffer(working_bufnr)
- end)
-
- it('matches with diff3 format', function()
- local working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '||||||| base',
- 'local x = 0',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, '/tmp/diffs_test_diff3.lua')
-
- local hunk = {
- index = 1,
- start_line = 3,
- end_line = 7,
- del_lines = { 'local x = 1' },
- add_lines = { 'local x = 2' },
- }
-
- local region = merge.match_hunk_to_conflict(hunk, working_bufnr)
- assert.is_not_nil(region)
- assert.are.equal(2, region.marker_base)
-
- helpers.delete_buffer(working_bufnr)
- end)
- end)
-
- describe('resolution', function()
- local diff_bufnr, working_bufnr
-
- local function setup_buffers()
- local working_path = '/tmp/diffs_test_resolve.lua'
- working_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- diff_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(diff_bufnr)
- end
-
- local function cleanup()
- helpers.delete_buffer(diff_bufnr)
- helpers.delete_buffer(working_bufnr)
- end
-
- it('resolve_ours keeps ours content in working file', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_ours(diff_bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('local x = 1', lines[1])
-
- cleanup()
- end)
-
- it('resolve_theirs keeps theirs content in working file', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_theirs(diff_bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('local x = 2', lines[1])
-
- cleanup()
- end)
-
- it('resolve_both keeps ours then theirs in working file', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_both(diff_bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
- assert.are.equal(2, #lines)
- assert.are.equal('local x = 1', lines[1])
- assert.are.equal('local x = 2', lines[2])
-
- cleanup()
- end)
-
- it('resolve_none removes entire block from working file', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_none(diff_bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false)
- assert.are.equal(1, #lines)
- assert.are.equal('', lines[1])
-
- cleanup()
- end)
-
- it('tracks resolved hunks', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- assert.is_false(merge.is_resolved(diff_bufnr, 1))
- merge.resolve_ours(diff_bufnr)
- assert.is_true(merge.is_resolved(diff_bufnr, 1))
-
- cleanup()
- end)
-
- it('matches and resolves conflicts from generated single-rail hunks', function()
- local working_path = '/tmp/diffs_test_single_rail_resolve.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local annotated, info = rails.annotate({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, { rail_style = 'single' })
- local d_bufnr = create_diff_buffer(annotated, working_path)
- vim.api.nvim_buf_set_var(d_bufnr, 'diffs_rail_width', info.prefix_width)
- vim.api.nvim_buf_set_var(d_bufnr, 'diffs_rail_style', info.style)
- vim.api.nvim_set_current_buf(d_bufnr)
-
- local hunks = merge.parse_hunks(d_bufnr)
- assert.are.equal(1, #hunks)
- assert.are.same({ 'local x = 1' }, hunks[1].del_lines)
- assert.are.same({ 'local x = 2' }, hunks[1].add_lines)
- assert.is_not_nil(merge.match_hunk_to_conflict(hunks[1], w_bufnr))
-
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
- merge.resolve_theirs(d_bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(w_bufnr, 0, -1, false)
- assert.are.same({ 'local x = 2' }, lines)
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('notifies when hunk is already resolved', function()
- setup_buffers()
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_ours(diff_bufnr)
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('already resolved') then
- notified = true
- end
- end
-
- merge.resolve_ours(diff_bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- cleanup()
- end)
-
- it('notifies when hunk does not match a conflict', function()
- local working_path = '/tmp/diffs_test_no_conflict.lua'
- local w_bufnr = create_working_buffer({
- 'local y = 1',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('does not correspond') then
- notified = true
- end
- end
-
- merge.resolve_ours(d_bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
- end)
-
- describe('navigation', function()
- it('goto_next jumps to next conflict hunk', function()
- local working_path = '/tmp/diffs_test_nav.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local a = 1',
- '=======',
- 'local a = 2',
- '>>>>>>> feature',
- 'middle',
- '<<<<<<< HEAD',
- 'local b = 3',
- '=======',
- 'local b = 4',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local a = 1',
- '+local a = 2',
- '@@ -5,1 +5,1 @@',
- '-local b = 3',
- '+local b = 4',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- merge.goto_next(d_bufnr)
- assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
-
- merge.goto_next(d_bufnr)
- assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('goto_next wraps around', function()
- local working_path = '/tmp/diffs_test_wrap.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 6, 0 })
-
- merge.goto_next(d_bufnr)
- assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('goto_next notifies on wrap-around', function()
- local working_path = '/tmp/diffs_test_wrap_notify.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 6, 0 })
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('wrapped to first hunk') then
- notified = true
- end
- end
-
- merge.goto_next(d_bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('goto_prev jumps to previous conflict hunk', function()
- local working_path = '/tmp/diffs_test_prev.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local a = 1',
- '=======',
- 'local a = 2',
- '>>>>>>> feature',
- 'middle',
- '<<<<<<< HEAD',
- 'local b = 3',
- '=======',
- 'local b = 4',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local a = 1',
- '+local a = 2',
- '@@ -5,1 +5,1 @@',
- '-local b = 3',
- '+local b = 4',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 9, 0 })
-
- merge.goto_prev(d_bufnr)
- assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
-
- merge.goto_prev(d_bufnr)
- assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('goto_prev wraps around', function()
- local working_path = '/tmp/diffs_test_prev_wrap.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- merge.goto_prev(d_bufnr)
- assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('goto_prev notifies on wrap-around', function()
- local working_path = '/tmp/diffs_test_prev_wrap_notify.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
-
- local notified = false
- local orig_notify = vim.notify
- vim.notify = function(msg)
- if msg:match('wrapped to last hunk') then
- notified = true
- end
- end
-
- merge.goto_prev(d_bufnr)
- vim.notify = orig_notify
-
- assert.is_true(notified)
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
-
- it('skips resolved hunks', function()
- local working_path = '/tmp/diffs_test_skip_resolved.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local a = 1',
- '=======',
- 'local a = 2',
- '>>>>>>> feature',
- 'middle',
- '<<<<<<< HEAD',
- 'local b = 3',
- '=======',
- 'local b = 4',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local a = 1',
- '+local a = 2',
- '@@ -5,1 +5,1 @@',
- '-local b = 3',
- '+local b = 4',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_ours(d_bufnr)
-
- vim.api.nvim_win_set_cursor(0, { 1, 0 })
- merge.goto_next(d_bufnr)
- assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1])
-
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
- end)
-
- describe('attach', function()
- it('clears resolved state on re-init', function()
- local working_path = '/tmp/diffs_test_reinit.lua'
- local w_bufnr = create_working_buffer({
- '<<<<<<< HEAD',
- 'local x = 1',
- '=======',
- 'local x = 2',
- '>>>>>>> feature',
- }, working_path)
-
- local d_bufnr = create_diff_buffer({
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,1 @@',
- '-local x = 1',
- '+local x = 2',
- }, working_path)
- vim.api.nvim_set_current_buf(d_bufnr)
- vim.api.nvim_win_set_cursor(0, { 5, 0 })
-
- merge.resolve_ours(d_bufnr)
- assert.is_true(merge.is_resolved(d_bufnr, 1))
-
- local ready_bufnr
- vim.api.nvim_create_autocmd('User', {
- pattern = 'DiffsMergeReady',
- once = true,
- callback = function(event)
- ready_bufnr = event.data.bufnr
- end,
- })
- merge.attach(d_bufnr)
- assert.are.equal(d_bufnr, ready_bufnr)
-
- assert.is_false(merge.is_resolved(d_bufnr, 1))
- helpers.delete_buffer(d_bufnr)
- helpers.delete_buffer(w_bufnr)
- end)
- end)
-
- describe('fugitive integration', function()
- it('parse_file_line returns status for unmerged files', function()
- local fugitive = require('diffs.fugitive')
- local buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
- 'Unstaged (1)',
- 'U conflict.lua',
- })
- local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2)
- assert.are.equal('conflict.lua', filename)
- assert.are.equal('unstaged', section)
- assert.is_false(is_header)
- assert.is_nil(old_filename)
- assert.are.equal('U', status)
- vim.api.nvim_buf_delete(buf, { force = true })
- end)
-
- it('walkback from hunk line propagates status', function()
- local fugitive = require('diffs.fugitive')
- local buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
- 'Unstaged (1)',
- 'U conflict.lua',
- '@@ -1,3 +1,4 @@',
- ' local M = {}',
- '+local new = true',
- })
- local _, _, _, _, status = fugitive.get_file_at_line(buf, 5)
- assert.are.equal('U', status)
- vim.api.nvim_buf_delete(buf, { force = true })
- end)
- end)
-end)
diff --git a/spec/parser_spec.lua b/spec/parser_spec.lua
index 16b11e26..fe482055 100644
--- a/spec/parser_spec.lua
+++ b/spec/parser_spec.lua
@@ -1,6 +1,5 @@
require('spec.helpers')
local parser = require('diffs.parser')
-local rails = require('diffs.rails')
describe('parser', function()
describe('parse_buffer', function()
@@ -56,61 +55,6 @@ describe('parser', function()
delete_buffer(bufnr)
end)
- it('strips generated line-number rails before parsing hunks', function()
- local annotated, info = rails.annotate({
- 'diff --git a/lua/test.lua b/lua/test.lua',
- '--- a/lua/test.lua',
- '+++ b/lua/test.lua',
- '@@ -1,2 +1,3 @@',
- ' local M = {}',
- '+local new = true',
- ' return M',
- })
- local bufnr = create_buffer(annotated)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width)
-
- local hunks = parser.parse_buffer(bufnr)
-
- assert.are.equal(1, #hunks)
- assert.are.equal('lua/test.lua', hunks[1].filename)
- assert.are.equal(info.prefix_width, hunks[1].quote_width)
- assert.are.equal(info.prefix_width, hunks[1].rail_width)
- assert.are.equal(4, hunks[1].start_line)
- assert.are.same({
- ' local M = {}',
- '+local new = true',
- ' return M',
- }, hunks[1].lines)
- delete_buffer(bufnr)
- end)
-
- it('records single generated rail style and keeps parsed hunk lines rail-free', function()
- local annotated, info = rails.annotate({
- 'diff --git a/lua/test.lua b/lua/test.lua',
- '--- a/lua/test.lua',
- '+++ b/lua/test.lua',
- '@@ -1,2 +1,3 @@',
- ' local M = {}',
- '-local old = false',
- '+local new = true',
- }, { rail_style = 'single' })
- local bufnr = create_buffer(annotated)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_width', info.prefix_width)
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', info.style)
-
- local hunks = parser.parse_buffer(bufnr)
-
- assert.are.equal(1, #hunks)
- assert.are.equal('single', hunks[1].rail_style)
- assert.are.equal(info.prefix_width, hunks[1].rail_width)
- assert.are.same({
- ' local M = {}',
- '-local old = false',
- '+local new = true',
- }, hunks[1].lines)
- delete_buffer(bufnr)
- end)
-
it('detects multiple hunks in same file', function()
local bufnr = create_buffer({
'M lua/test.lua',
@@ -432,33 +376,6 @@ describe('parser', function()
delete_buffer(bufnr)
end)
- it('uses filetype from existing buffer when available', function()
- local repo_root = '/tmp/test-repo'
- local file_path = repo_root .. '/build'
-
- local file_buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(file_buf, file_path)
- vim.api.nvim_set_option_value('filetype', 'bash', { buf = file_buf })
-
- local diff_buf = create_buffer({
- 'M build',
- '@@ -1,2 +1,3 @@',
- ' echo "hello"',
- '+set -e',
- ' echo "done"',
- })
- vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = repo_root })
-
- local hunks = parser.parse_buffer(diff_buf)
-
- assert.are.equal(1, #hunks)
- assert.are.equal('build', hunks[1].filename)
- assert.are.equal('bash', hunks[1].ft)
-
- delete_buffer(file_buf)
- delete_buffer(diff_buf)
- end)
-
it('uses filetype from existing buffer via git_dir', function()
local git_dir = '/tmp/test-repo/.git'
local repo_root = '/tmp/test-repo'
@@ -505,7 +422,7 @@ describe('parser', function()
'+set -e',
' echo "hello"',
})
- vim.api.nvim_buf_set_var(diff_buf, 'diffs_source', { repo_root = repo_root })
+ vim.api.nvim_buf_set_var(diff_buf, 'git_dir', repo_root .. '/.git')
local hunks = parser.parse_buffer(diff_buf)
diff --git a/spec/plugin_spec.lua b/spec/plugin_spec.lua
index b77a64cf..64e73a0b 100644
--- a/spec/plugin_spec.lua
+++ b/spec/plugin_spec.lua
@@ -29,28 +29,6 @@ describe('plugin bootstrap', function()
return output
end
- it('registers entry points without loading implementation modules', function()
- local init_lines = {
- ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())),
- }
- local after_lines = {
- 'local commands = vim.api.nvim_get_commands({})',
- "print('has_diff_command=' .. tostring(commands.Diff ~= nil))",
- "print('diff_bar=' .. tostring(commands.Diff and commands.Diff.bar == true))",
- "print('commands_loaded=' .. tostring(package.loaded['diffs.commands'] ~= nil))",
- "print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil))",
- "print('highlight_loaded=' .. tostring(package.loaded['diffs.highlight'] ~= nil))",
- }
-
- local output = run_child(init_lines, after_lines)
-
- assert.matches('has_diff_command=true', output, 1, true)
- assert.matches('diff_bar=true', output, 1, true)
- assert.matches('commands_loaded=false', output, 1, true)
- assert.matches('runtime_loaded=false', output, 1, true)
- assert.matches('highlight_loaded=false', output, 1, true)
- end)
-
it('does not load runtime for an ordinary file', function()
local init_lines = {
('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())),
@@ -60,67 +38,12 @@ describe('plugin bootstrap', function()
"vim.fn.writefile({ 'ordinary source line' }, path)",
'vim.cmd.edit(path)',
"print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil))",
- "print('conflict_loaded=' .. tostring(package.loaded['diffs.conflict'] ~= nil))",
'vim.fn.delete(path)',
}
local output = run_child(init_lines, after_lines)
assert.matches('runtime_loaded=false', output, 1, true)
- assert.matches('conflict_loaded=false', output, 1, true)
- end)
-
- it('respects the conflict setting', function()
- for _, enabled in ipairs({ true, false }) do
- local init_lines = {
- ('vim.g.diffs = { conflict = %s }'):format(tostring(enabled)),
- ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())),
- }
- local after_lines = vim.split(
- [[
-local path = vim.fn.tempname()
-vim.fn.writefile({ '<<<<<<< ours', 'old', '=======', 'new', '>>>>>>> theirs' }, path)
-vim.cmd.edit(path)
-print('runtime_loaded=' .. tostring(package.loaded['diffs.runtime'] ~= nil))
-print('conflict_loaded=' .. tostring(package.loaded['diffs.conflict'] ~= nil))
-
-if not vim.g.diffs.conflict then
- local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
- vim.fn.maparg('(diffs-conflict-ours)', 'n', false, true).callback()
- print('unchanged=' .. tostring(vim.deep_equal(lines, vim.api.nvim_buf_get_lines(0, 0, -1, false))))
-end
-
-for _, kind in ipairs({ 'conflict', 'merge' }) do
- if kind == 'merge' then
- local buf = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, { '@@ -1 +1 @@', '-old', '+new' })
- vim.b[buf].diffs_source = { working_path = path }
- vim.api.nvim_set_current_buf(buf)
- end
- for _, direction in ipairs({ 'next', 'prev' }) do
- vim.api.nvim_win_set_cursor(0, { 2, 0 })
- vim.fn.maparg('(diffs-' .. kind .. '-' .. direction .. ')', 'n', false, true).callback()
- print(kind .. '_' .. direction .. '=' .. vim.api.nvim_win_get_cursor(0)[1])
- end
-end
-vim.fn.delete(path)
-]],
- '\n'
- )
-
- local output = run_child(init_lines, after_lines)
-
- assert.matches('runtime_loaded=' .. tostring(enabled), output, 1, true)
- assert.matches('conflict_loaded=' .. tostring(enabled), output, 1, true)
- for _, kind in ipairs({ 'conflict', 'merge' }) do
- for _, direction in ipairs({ 'next', 'prev' }) do
- assert.matches(kind .. '_' .. direction .. '=' .. (enabled and 1 or 2), output, 1, true)
- end
- end
- if not enabled then
- assert.matches('unchanged=true', output, 1, true)
- end
- end
end)
it('loads supported config during plugin startup', function()
@@ -129,7 +52,7 @@ vim.fn.delete(path)
'vim.notify = function(message, level)',
'_G.diffs_notifications[#_G.diffs_notifications + 1] = { message = message, level = level }',
'end',
- 'vim.g.diffs = { view = { prefix = false }, highlights = { background = false }, conflict = false, integrations = { "fugitive", "neogit", "neojj", "gitsigns", "committia", "telescope" } }',
+ 'vim.g.diffs = { view = { prefix = false }, highlights = { background = false }, integrations = { "fugitive", "neogit", "neojj", "gitsigns", "committia", "telescope" } }',
('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())),
}
@@ -151,7 +74,6 @@ vim.fn.delete(path)
"print('runtime_committia=' .. tostring(runtime_config.integrations.committia))",
"print('runtime_telescope=' .. tostring(runtime_config.integrations.telescope))",
"print('runtime_background=' .. tostring(runtime_config.highlights.background))",
- "print('runtime_conflict=' .. tostring(runtime_config.conflict))",
"print('runtime_config_priorities=' .. tostring(runtime_config.highlights.priorities))",
"print('highlight_priority_clear=' .. tostring(highlight_opts.highlights.priorities.clear))",
"print('highlight_priority_syntax=' .. tostring(highlight_opts.highlights.priorities.syntax))",
@@ -188,7 +110,6 @@ vim.fn.delete(path)
assert.matches('runtime_committia=true', output, 1, true)
assert.matches('runtime_telescope=true', output, 1, true)
assert.matches('runtime_background=false', output, 1, true)
- assert.matches('runtime_conflict=false', output, 1, true)
assert.matches('runtime_config_priorities=nil', output, 1, true)
assert.matches('highlight_priority_clear=198', output, 1, true)
assert.matches('highlight_priority_syntax=199', output, 1, true)
@@ -201,113 +122,6 @@ vim.fn.delete(path)
assert.matches('has_telescope_autocmd=true', output, 1, true)
end)
- it('navigates both review panes and preserves quickfix entries across reloads', function()
- local init_lines = {
- ('vim.opt.runtimepath:prepend(%s)'):format(vim.inspect(vim.fn.getcwd())),
- 'vim.g.diffs = { integrations = { difftastic = false } }',
- }
- local after_lines = vim.split(
- [[
-local repo = vim.fn.resolve(vim.fn.tempname())
-vim.fn.mkdir(repo, 'p')
-local function git(...)
- local output = vim.fn.systemlist({ 'git', '-C', repo, ... })
- assert(vim.v.shell_error == 0, table.concat(output, '\n'))
-end
-local ok, err = pcall(function()
- git('init', '-q')
- git('config', 'user.name', 'Test')
- git('config', 'user.email', 'test@example.com')
- for _, file in ipairs({ 'one.lua', 'three.lua', 'two.lua' }) do
- vim.fn.writefile({ 'old' }, repo .. '/' .. file)
- end
- git('add', '.')
- git('commit', '-qm', 'base')
- for _, file in ipairs({ 'one.lua', 'three.lua', 'two.lua' }) do
- vim.fn.writefile({ 'new' }, repo .. '/' .. file)
- end
- git('commit', '-qam', 'target')
- vim.cmd.cd(repo)
- vim.cmd('Diff review HEAD~1')
- local buf = vim.api.nvim_get_current_buf()
- local window_count = #vim.api.nvim_tabpage_list_wins(0)
- local statuscolumn = vim.wo.statuscolumn
- local before = vim.fn.getqflist({ id = 0, items = 0 })
- assert(#before.items == 3)
- vim.keymap.set('n', ']q', 'cnext')
- vim.keymap.set('n', '[q', 'cprevious')
- vim.cmd('normal gs')
- assert(not vim.api.nvim_buf_is_loaded(buf))
- local pair_wins = vim.tbl_filter(function(win)
- return require('diffs').review_current(vim.api.nvim_win_get_buf(win)) ~= nil
- end, vim.api.nvim_tabpage_list_wins(0))
- assert(#pair_wins == 2)
- local reads = 0
- local autocmd = vim.api.nvim_create_autocmd('BufReadCmd', {
- pattern = 'diffs://review:*',
- callback = function() reads = reads + 1 end,
- })
- local function check_pair(index)
- assert(#vim.api.nvim_tabpage_list_wins(0) == window_count + 1)
- for _, win in ipairs(pair_wins) do
- local pane = vim.api.nvim_win_get_buf(win)
- local current = require('diffs').review_current(pane)
- assert(current and current.index == index, vim.inspect(current))
- assert(vim.bo[pane].modifiable == false)
- assert(vim.wo[win].scrollbind and vim.wo[win].cursorbind)
- end
- assert(reads == 0)
- assert(vim.fn.getqflist({ id = 0 }).id == before.id)
- end
- check_pair(1)
- vim.cmd('normal ]q')
- check_pair(2)
- vim.api.nvim_set_current_win(pair_wins[1])
- vim.cmd('normal ]q')
- check_pair(3)
- vim.cmd('normal ]q')
- check_pair(1)
- vim.cmd('normal [q')
- check_pair(3)
- vim.cmd('normal 2[q')
- check_pair(1)
- vim.cmd('normal 2]q')
- check_pair(3)
- vim.api.nvim_del_autocmd(autocmd)
- vim.cmd('normal gs')
- assert(#vim.api.nvim_tabpage_list_wins(0) == window_count)
- buf = vim.api.nvim_get_current_buf()
- assert(vim.b[buf].diffs_review.layout == 'unified')
- before = vim.fn.getqflist({ id = 0, items = 0 })
- vim.cmd('normal gs')
- assert(require('diffs').review_current().index == 3)
- vim.cmd('normal q')
- local remaining_windows = #vim.api.nvim_tabpage_list_wins(0)
- vim.cmd('cc 2')
- local after = vim.fn.getqflist({ id = 0, idx = 0 })
- assert(after.id == before.id)
- assert(after.idx == 2)
- assert(vim.api.nvim_get_current_buf() == buf)
- assert(vim.api.nvim_win_get_cursor(0)[1] == before.items[2].lnum)
- assert(#vim.api.nvim_tabpage_list_wins(0) == remaining_windows)
- assert(not vim.wo.scrollbind)
- assert(not vim.wo.cursorbind)
- assert(vim.wo.statuscolumn == statuscolumn)
- vim.fn.writefile({ 'new file' }, repo .. '/four.lua')
- vim.cmd('edit')
- assert(#vim.fn.getqflist() == 4)
-end)
-vim.fn.delete(repo, 'rf')
-assert(ok, err)
-print('quickfix_reloaded=true')
-]],
- '\n'
- )
-
- local output = run_child(init_lines, after_lines)
- assert.matches('quickfix_reloaded=true', output, 1, true)
- end)
-
it('enhances native diff windows opened at startup with nvim -d', function()
local tmpdir = vim.fn.resolve(vim.fn.tempname())
assert.are.equal(1, vim.fn.mkdir(tmpdir, 'p'))
diff --git a/spec/rails_spec.lua b/spec/rails_spec.lua
deleted file mode 100644
index 44727f6b..00000000
--- a/spec/rails_spec.lua
+++ /dev/null
@@ -1,154 +0,0 @@
-local helpers = require('spec.helpers')
-
-local rails = require('diffs.rails')
-
-describe('diffs.rails', function()
- it('adds old and new line-number rails to unified diff lines', function()
- local annotated, info = rails.annotate({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -9,3 +10,4 @@',
- ' alpha',
- '-beta',
- '+beta changed',
- ' gamma',
- '+delta',
- '\\ No newline at end of file',
- })
-
- assert.are.same({
- width = 2,
- prefix_width = 12,
- style = 'dual',
- }, info)
- assert.are.equal(' │ diff --git a/file.txt b/file.txt', annotated[1])
- assert.are.equal(' │ @@ -9,3 +10,4 @@', annotated[4])
- assert.are.equal(' 9 10 │ alpha', annotated[5])
- assert.are.equal(' 10 │ -beta', annotated[6])
- assert.are.equal(' 11 │ +beta changed', annotated[7])
- assert.are.equal(' 11 12 │ gamma', annotated[8])
- assert.are.equal(' 13 │ +delta', annotated[9])
- assert.are.equal(' │ \\ No newline at end of file', annotated[10])
- end)
-
- it('leaves non-diff content unchanged', function()
- local lines = { 'not a diff' }
- local annotated, info = rails.annotate(lines)
-
- assert.are.equal(lines, annotated)
- assert.is_nil(info)
- end)
-
- it('adds a single side-aware line-number rail to unified diff lines', function()
- local annotated, info = rails.annotate({
- 'diff --git a/file.txt b/file.txt',
- '--- a/file.txt',
- '+++ b/file.txt',
- '@@ -9,3 +10,4 @@',
- ' alpha',
- '-beta',
- '+beta changed',
- ' gamma',
- '+delta',
- '\\ No newline at end of file',
- }, { rail_style = 'single' })
-
- assert.are.same({
- width = 2,
- prefix_width = 9,
- style = 'single',
- }, info)
- assert.are.equal(' │ diff --git a/file.txt b/file.txt', annotated[1])
- assert.are.equal(' │ @@ -9,3 +10,4 @@', annotated[4])
- assert.are.equal(' 10 │ alpha', annotated[5])
- assert.are.equal(' 10 │ -beta', annotated[6])
- assert.are.equal(' 11 │ +beta changed', annotated[7])
- assert.are.equal(' 12 │ gamma', annotated[8])
- assert.are.equal(' 13 │ +delta', annotated[9])
- assert.are.equal(' │ \\ No newline at end of file', annotated[10])
- end)
-
- it('does not render trailing spaces for empty context lines', function()
- local annotated, info = rails.annotate({
- 'diff --git a/file.txt b/file.txt',
- '@@ -1,2 +1,2 @@',
- ' line',
- ' ',
- })
-
- assert.are.same({
- width = 1,
- prefix_width = 10,
- style = 'dual',
- }, info)
- assert.are.equal(' 2 2 │', annotated[4])
- assert.is_nil(annotated[4]:match('%s$'))
- assert.are.equal(' ', rails.strip(annotated[4], info.prefix_width))
- end)
-
- it('strips single rail display lines back to raw unified diff lines', function()
- local lines = {
- 'diff --git a/file.txt b/file.txt',
- '@@ -1,3 +1,3 @@',
- ' first',
- ' ',
- '-old',
- '+new',
- }
- local annotated, info = rails.annotate(lines, { rail_style = 'single' })
-
- assert.are.equal(' 2 │', annotated[4])
- assert.is_nil(annotated[4]:match('%s$'))
- assert.are.equal(' ', rails.strip(annotated[4], info.prefix_width))
- assert.are.same(lines, rails.strip_lines(annotated, info.prefix_width))
- end)
-
- it('returns byte ranges for old and new rail number columns', function()
- assert.are.same({
- old_start = 2,
- old_end = 4,
- new_start = 5,
- new_end = 7,
- }, rails.ranges(12))
- end)
-
- it('returns style-aware byte ranges for rail number columns', function()
- assert.are.same({
- {
- start = 2,
- finish = 4,
- },
- {
- start = 5,
- finish = 7,
- },
- }, rails.number_ranges(12, 'dual'))
- assert.are.same({
- {
- start = 2,
- finish = 4,
- },
- }, rails.number_ranges(9, 'single'))
- end)
-
- it('reads buffer rail style with a dual compatibility default', function()
- local bufnr = helpers.create_buffer({})
-
- assert.are.equal('dual', rails.style_for_buffer(bufnr))
-
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'single')
- assert.are.equal('single', rails.style_for_buffer(bufnr))
-
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'dual')
- assert.are.equal('dual', rails.style_for_buffer(bufnr))
-
- vim.api.nvim_buf_set_var(bufnr, 'diffs_rail_style', 'stacked')
- assert.are.equal('dual', rails.style_for_buffer(bufnr))
-
- pcall(vim.api.nvim_buf_del_var, bufnr, 'diffs_rail_style')
- assert.are.equal('dual', rails.style_for_buffer(bufnr))
-
- helpers.delete_buffer(bufnr)
- end)
-end)
diff --git a/spec/read_buffer_spec.lua b/spec/read_buffer_spec.lua
deleted file mode 100644
index 9429f3f9..00000000
--- a/spec/read_buffer_spec.lua
+++ /dev/null
@@ -1,660 +0,0 @@
-local helpers = require('spec.helpers')
-
-local commands = require('diffs.commands')
-local diffspec = require('diffs.spec')
-local generated = require('diffs.generated')
-local git = require('diffs.git')
-local rails = require('diffs.rails')
-local runtime = require('diffs.runtime')
-
-local saved_git = {}
-local saved_notify
-local saved_systemlist
-local saved_jobstart
-local saved_jobwait
-local test_buffers = {}
-
-local function mock_git(overrides)
- overrides = overrides or {}
- saved_git.get_file_content = git.get_file_content
- saved_git.get_index_content = git.get_index_content
- saved_git.get_working_content = git.get_working_content
-
- git.get_file_content = overrides.get_file_content
- or function()
- return { 'local M = {}', 'return M' }
- end
- git.get_index_content = overrides.get_index_content
- or function()
- return { 'local M = {}', 'return M' }
- end
- git.get_working_content = overrides.get_working_content
- or function()
- return { 'local M = {}', 'local x = 1', 'return M' }
- end
-end
-
-local function mock_systemlist(fn)
- saved_systemlist = vim.fn.systemlist
- saved_jobstart = vim.fn.jobstart
- saved_jobwait = vim.fn.jobwait
- local exit_code
- vim.fn.jobstart = function(cmd, opts)
- local result, code, stderr = fn(cmd)
- exit_code = code or 0
- local stdout = exit_code == 0 and vim.deepcopy(result or {}) or {}
- stderr = stderr or (exit_code ~= 0 and vim.deepcopy(result or {}) or {})
- stdout[#stdout + 1] = ''
- stderr[#stderr + 1] = ''
- opts.on_stdout(1, stdout)
- opts.on_stderr(1, stderr)
- return 1
- end
- vim.fn.jobwait = function()
- return { exit_code }
- end
- vim.fn.systemlist = function(cmd)
- local result, shell_error = fn(cmd)
- if shell_error and shell_error ~= 0 then
- saved_systemlist({ 'false' })
- else
- saved_systemlist({ 'true' })
- end
- return result
- end
-end
-
-local function mock_notify(fn)
- saved_notify = vim.notify
- vim.notify = fn
-end
-
-local function restore_mocks()
- for k, v in pairs(saved_git) do
- git[k] = v
- end
- saved_git = {}
- if saved_notify then
- vim.notify = saved_notify
- saved_notify = nil
- end
- if saved_jobstart then
- vim.fn.jobstart = saved_jobstart
- saved_jobstart = nil
- end
- if saved_jobwait then
- vim.fn.jobwait = saved_jobwait
- saved_jobwait = nil
- end
- if saved_systemlist then
- vim.fn.systemlist = saved_systemlist
- saved_systemlist = nil
- end
-end
-
----@param name string
----@param vars? table
----@return integer
-local function create_diffs_buffer(name, vars)
- local existing = vim.fn.bufnr(name)
- if existing ~= -1 then
- vim.api.nvim_buf_delete(existing, { force = true })
- end
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_name(bufnr, name)
- vars = vars or {}
- for k, v in pairs(vars) do
- vim.api.nvim_buf_set_var(bufnr, k, v)
- end
- table.insert(test_buffers, bufnr)
- return bufnr
-end
-
-local function cleanup_buffers()
- for _, bufnr in ipairs(test_buffers) do
- if vim.api.nvim_buf_is_valid(bufnr) then
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end
- end
- test_buffers = {}
-end
-
-local function buffer_lines(bufnr)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- return rails.strip_lines(lines, rails.width_for_buffer(bufnr))
-end
-
-local function review_diff_lines()
- return {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1 +1 @@',
- '-old',
- '+new',
- }
-end
-
-local function has_buf_var(bufnr, name)
- local ok = pcall(vim.api.nvim_buf_get_var, bufnr, name)
- return ok
-end
-
-describe('read_buffer', function()
- after_each(function()
- restore_mocks()
- cleanup_buffers()
- end)
-
- describe('early returns', function()
- it('does nothing on non-diffs:// buffer', function()
- local bufnr = vim.api.nvim_create_buf(false, true)
- table.insert(test_buffers, bufnr)
- assert.has_no.errors(function()
- commands.read_buffer(bufnr)
- end)
- assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- end)
-
- it('leaves content unchanged when source metadata is missing', function()
- local notification
- mock_notify(function(message, level)
- notification = { message = message, level = level }
- end)
- local bufnr = create_diffs_buffer('diffs://staged:missing_source.lua')
- assert.has_no.errors(function()
- commands.read_buffer(bufnr)
- end)
- assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(notification.message:find('without diffs_source metadata', 1, true) ~= nil)
- end)
- end)
-
- describe('unloaded buffers', function()
- it('regenerates content after an unload cleared the buffer variables', function()
- mock_git()
-
- local bufnr = commands._test.create_generated_diff_buffer({
- name = 'diffs://unstaged:unloaded.lua',
- lines = review_diff_lines(),
- repo_root = '/tmp',
- source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp',
- spec = diffspec.index_to_worktree('unloaded.lua'),
- },
- rail_style = 'single',
- })
- table.insert(test_buffers, bufnr)
-
- vim.api.nvim_win_set_buf(0, bufnr)
- vim.cmd('enew')
-
- assert.is_false(vim.api.nvim_buf_is_loaded(bufnr))
- assert.is_false(has_buf_var(bufnr, 'diffs_source'))
- assert.is_false(has_buf_var(bufnr, 'diffs_repo_root'))
-
- commands.read_buffer(bufnr)
-
- local lines = buffer_lines(bufnr)
- assert.are.equal('diff --git a/unloaded.lua b/unloaded.lua', lines[1])
- assert.are.equal('+local x = 1', lines[6])
- assert.are.equal('/tmp', vim.api.nvim_buf_get_var(bufnr, 'diffs_source').repo_root)
- assert.are.equal('single', vim.api.nvim_buf_get_var(bufnr, 'diffs_rail_style'))
- end)
-
- it('restores review metadata and keymaps for an unloaded review map', function()
- mock_systemlist(function(cmd)
- if cmd[4] == 'rev-parse' then
- return { 'commit' }
- end
- if cmd[4] ~= 'diff' then
- return {}
- end
- return review_diff_lines()
- end)
-
- local bufnr = commands._test.create_generated_diff_buffer({
- name = 'diffs://review:origin/main...feature/topic',
- lines = review_diff_lines(),
- repo_root = '/tmp/repo',
- source = {
- version = 1,
- kind = 'review',
- repo_root = '/tmp/repo',
- review = {
- base = 'origin/main',
- target = 'feature/topic',
- mode = 'merge-base',
- },
- },
- vars = {
- diffs_review = { display = 'origin/main...feature/topic', layout = 'stacked' },
- },
- })
- table.insert(test_buffers, bufnr)
-
- vim.api.nvim_win_set_buf(0, bufnr)
- vim.cmd('enew')
-
- commands.read_buffer(bufnr)
-
- assert.are.same(review_diff_lines(), buffer_lines(bufnr))
- assert.are.same(
- { display = 'origin/main...feature/topic', layout = 'stacked' },
- vim.api.nvim_buf_get_var(bufnr, 'diffs_review')
- )
- assert.is_true(helpers.has_keymap(bufnr, 'gs'))
- end)
- end)
-
- describe('buffer options', function()
- it('sets buftype, bufhidden, swapfile, modifiable, filetype', function()
- mock_git()
- local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', {
- diffs_source = generated.file_source('/tmp', diffspec.head_to_index('options_test.lua')),
- })
-
- commands.read_buffer(bufnr)
-
- assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
- assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr }))
- assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr }))
- assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr }))
- assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
- end)
- end)
-
- describe('dispatch', function()
- it('uses diffs_source metadata without parsing the display name', function()
- local calls = {}
- mock_git({
- get_file_content = function(rev, filepath)
- table.insert(calls, { 'file', rev, filepath })
- return { 'tree' }
- end,
- get_index_content = function(filepath)
- table.insert(calls, { 'index', filepath })
- return { 'index' }
- end,
- get_working_content = function(filepath)
- table.insert(calls, { 'worktree', filepath })
- return { 'worktree' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://metadata-only', {
- diffs_source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp',
- spec = diffspec.index_to_worktree('source_meta.lua'),
- },
- })
- commands.read_buffer(bufnr)
-
- assert.are.same({
- { 'index', '/tmp/source_meta.lua' },
- { 'worktree', '/tmp/source_meta.lua' },
- }, calls)
- local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('source_meta.lua', diff_hunks[1].file)
- assert.is_true(diff_hunks[1].can_put)
- assert.is_false(diff_hunks[1].can_obtain)
- assert.is_true(diff_hunks[1].actionable)
- end)
-
- it('rejects standalone split endpoint reloads without adding pair actions', function()
- local called_index = false
- local notification
- mock_notify(function(message, level)
- notification = { message = message, level = level }
- end)
- mock_git({
- get_index_content = function()
- called_index = true
- return { 'local M = {}', 'return M' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', {
- diffs_source = {
- version = 1,
- kind = 'split_endpoint',
- repo_root = '/tmp/repo',
- spec = diffspec.index_to_worktree('lua/foo.lua'),
- side = 'left',
- path = 'lua/foo.lua',
- filetype = 'lua',
- },
- })
-
- commands.read_buffer(bufnr)
-
- assert.is_false(called_index)
- assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- assert.is_false(has_buf_var(bufnr, 'diffs_split_side'))
- assert.is_false(has_buf_var(bufnr, 'diffs_split_peer'))
- assert.is_false(helpers.has_keymap(bufnr, 'q'))
- assert.is_false(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, ']c'))
- assert.is_false(helpers.has_keymap(bufnr, '[c'))
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(notification.message:find('without a valid peer', 1, true) ~= nil)
- end)
-
- it('reloads both split endpoints from paired source metadata', function()
- mock_git()
-
- local source = {
- version = 1,
- kind = 'split_endpoint',
- repo_root = '/tmp/repo',
- spec = diffspec.index_to_worktree('lua/foo.lua'),
- path = 'lua/foo.lua',
- filetype = 'lua',
- }
- local left_buf = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', {
- diffs_source = vim.tbl_extend('force', source, { side = 'left' }),
- })
- local right_buf = create_diffs_buffer('diffs://split:right:worktree:lua/foo.lua', {
- diffs_source = vim.tbl_extend('force', source, { side = 'right' }),
- })
- vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf)
- vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf)
- vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, { 'stale left' })
- vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { 'stale right' })
-
- commands.read_buffer(left_buf)
-
- local left_lines = vim.api.nvim_buf_get_lines(left_buf, 0, -1, false)
- local right_lines = vim.api.nvim_buf_get_lines(right_buf, 0, -1, false)
- assert.are.equal(#left_lines, #right_lines)
- assert.are.equal('local M = {}', left_lines[1])
- assert.are.equal('local M = {}', right_lines[1])
- assert.are.equal('local x = 1', right_lines[2])
- assert.is_true(vim.tbl_contains(left_lines, ''))
- assert.are.equal(right_buf, vim.api.nvim_buf_get_var(left_buf, 'diffs_split_peer'))
- assert.are.equal(left_buf, vim.api.nvim_buf_get_var(right_buf, 'diffs_split_peer'))
- end)
-
- it('does not partially reload paired split endpoints when the peer read fails', function()
- mock_notify(function() end)
- mock_git({
- get_index_content = function()
- return { 'new left' }
- end,
- get_working_content = function()
- return nil, 'read failed'
- end,
- })
-
- local source = {
- version = 1,
- kind = 'split_endpoint',
- repo_root = '/tmp/repo',
- spec = diffspec.index_to_worktree('lua/foo.lua'),
- path = 'lua/foo.lua',
- filetype = 'lua',
- }
- local left_buf = create_diffs_buffer('diffs://split:left:index:lua/foo.lua', {
- diffs_source = vim.tbl_extend('force', source, { side = 'left' }),
- })
- local right_buf = create_diffs_buffer('diffs://split:right:worktree:lua/foo.lua', {
- diffs_source = vim.tbl_extend('force', source, { side = 'right' }),
- })
- vim.api.nvim_buf_set_var(left_buf, 'diffs_split_peer', right_buf)
- vim.api.nvim_buf_set_var(right_buf, 'diffs_split_peer', left_buf)
- vim.api.nvim_buf_set_lines(left_buf, 0, -1, false, { 'stale left' })
- vim.api.nvim_buf_set_lines(right_buf, 0, -1, false, { 'stale right' })
-
- commands.read_buffer(left_buf)
-
- assert.are.same({ 'stale left' }, vim.api.nvim_buf_get_lines(left_buf, 0, -1, false))
- assert.are.same({ 'stale right' }, vim.api.nvim_buf_get_lines(right_buf, 0, -1, false))
- end)
-
- it('reloads untracked DiffSpec buffers with empty index content and hunk metadata', function()
- mock_git({
- get_index_content = function(filepath)
- assert.are.equal('/tmp/new.lua', filepath)
- return nil, 'file not in index'
- end,
- get_working_content = function(filepath)
- assert.are.equal('/tmp/new.lua', filepath)
- return { 'local M = {}', 'return M' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://untracked:new.lua', {
- diffs_source = generated.file_source('/tmp', diffspec.index_to_worktree('new.lua')),
- })
- commands.read_buffer(bufnr)
-
- local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n')
- assert.is_true(text:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) ~= nil)
- local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('new.lua', diff_hunks[1].file)
- assert.is_true(diff_hunks[1].actionable)
- end)
-
- it(
- 'reloads staged deletion DiffSpec buffers with empty index content and hunk metadata',
- function()
- mock_git({
- get_file_content = function(rev, filepath)
- assert.are.equal('HEAD', rev)
- assert.are.equal('/tmp/deleted.lua', filepath)
- return { 'local M = {}', 'return M' }
- end,
- get_index_content = function(filepath)
- assert.are.equal('/tmp/deleted.lua', filepath)
- return nil, 'file not in index'
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://staged:deleted.lua', {
- diffs_source = generated.file_source('/tmp', diffspec.head_to_index('deleted.lua')),
- })
- commands.read_buffer(bufnr)
-
- local text = table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n')
- assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil)
- local diff_hunks = vim.api.nvim_buf_get_var(bufnr, 'diffs_hunks')
- assert.are.equal(1, #diff_hunks)
- assert.are.equal('deleted.lua', diff_hunks[1].file)
- assert.are.equal('index', diff_hunks[1].mutation_target)
- end
- )
-
- it('warns and leaves content unchanged for invalid diffs_source metadata', function()
- local notification
- mock_notify(function(message, level)
- notification = { message = message, level = level }
- end)
-
- local bufnr = create_diffs_buffer('diffs://unstaged:invalid_source.lua', {
- diffs_source = {
- version = 1,
- kind = 'file',
- repo_root = '/tmp',
- },
- })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale content' })
-
- commands.read_buffer(bufnr)
-
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(notification.message:find('invalid diffs_source metadata', 1, true) ~= nil)
- assert.are.same({ 'stale content' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- end)
-
- it('reports missing review refs from source metadata without replacing buffer lines', function()
- local called_diff = false
- local notification
- local operation_repo
- mock_notify(function(message, level)
- notification = { message = message, level = level }
- end)
- mock_systemlist(function(cmd)
- if cmd[4] == 'diff' then
- called_diff = true
- elseif cmd[4] == 'merge-base' then
- operation_repo = cmd[3]
- return { 'fatal: Not a valid object name missing/ref' }, 128
- end
- return {}
- end)
-
- local bufnr = create_diffs_buffer('diffs://review:missing/ref', {
- diffs_source = {
- version = 1,
- kind = 'review',
- repo_root = '/home/test/repo',
- review = {
- base = 'missing/ref',
- },
- },
- })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale review' })
-
- commands.read_buffer(bufnr)
-
- assert.is_false(called_diff)
- assert.are.equal('/home/test/repo', operation_repo)
- assert.are.same({ 'stale review' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
- assert.is_not_nil(notification)
- assert.are.equal(vim.log.levels.WARN, notification.level)
- assert.is_true(
- notification.message:find('fatal: Not a valid object name missing/ref', 1, true) ~= nil
- )
- end)
- end)
-
- describe('content', function()
- it('generates valid unified diff header with correct paths', function()
- mock_git({
- get_file_content = function()
- return { 'old' }
- end,
- get_working_content = function()
- return { 'new' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', {
- diffs_source = generated.file_source(
- '/tmp',
- diffspec.rev_to_worktree('HEAD', 'lua/diffs/init.lua')
- ),
- })
- commands.read_buffer(bufnr)
-
- local lines = buffer_lines(bufnr)
- assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1])
- assert.are.equal('--- a/lua/diffs/init.lua', lines[2])
- assert.are.equal('+++ b/lua/diffs/init.lua', lines[3])
- end)
-
- it('uses both source paths for diff headers in renames', function()
- mock_git({
- get_file_content = function(_, path)
- assert.are.equal('/tmp/old_name.lua', path)
- return { 'old content' }
- end,
- get_index_content = function()
- return { 'new content' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', {
- diffs_source = generated.file_pair_source('/tmp', 'staged', 'new_name.lua', 'old_name.lua'),
- })
- commands.read_buffer(bufnr)
-
- local lines = buffer_lines(bufnr)
- assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1])
- assert.are.equal('--- a/old_name.lua', lines[2])
- assert.are.equal('+++ b/new_name.lua', lines[3])
- end)
-
- it('produces empty buffer when old and new are identical', function()
- mock_git({
- get_file_content = function()
- return { 'identical' }
- end,
- get_working_content = function()
- return { 'identical' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', {
- diffs_source = generated.file_source(
- '/tmp',
- diffspec.rev_to_worktree('HEAD', 'nodiff.lua')
- ),
- })
- commands.read_buffer(bufnr)
-
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- assert.are.same({ '' }, lines)
- end)
-
- it('replaces existing buffer content on reload', function()
- mock_git({
- get_file_content = function()
- return { 'old' }
- end,
- get_working_content = function()
- return { 'new' }
- end,
- })
-
- local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', {
- diffs_source = generated.file_source(
- '/tmp',
- diffspec.rev_to_worktree('HEAD', 'replace_test.lua')
- ),
- })
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' })
-
- commands.read_buffer(bufnr)
-
- local lines = buffer_lines(bufnr)
- assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1])
- for _, line in ipairs(lines) do
- assert.is_not_equal('stale', line)
- end
- end)
- end)
-
- describe('attach integration', function()
- it('calls attach on the buffer', function()
- mock_git()
-
- local attach_called_with
- local original_attach = runtime.attach
- runtime.attach = function(bufnr)
- attach_called_with = bufnr
- end
-
- local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', {
- diffs_source = generated.file_source('/tmp', diffspec.head_to_index('attach_test.lua')),
- })
- commands.read_buffer(bufnr)
-
- assert.are.equal(bufnr, attach_called_with)
-
- runtime.attach = original_attach
- end)
- end)
-end)
diff --git a/spec/render_spec.lua b/spec/render_spec.lua
deleted file mode 100644
index ad6c00e7..00000000
--- a/spec/render_spec.lua
+++ /dev/null
@@ -1,311 +0,0 @@
-require('spec.helpers')
-
-local diffspec = require('diffs.spec')
-local git = require('diffs.git')
-local render = require('diffs.render')
-
-local saved_git = {}
-local test_repos = {}
-local original_diffopt = vim.o.diffopt
-
-local function git_cmd(repo_root, args)
- local cmd = { 'git', '-C', repo_root }
- for _, arg in ipairs(args) do
- cmd[#cmd + 1] = arg
- end
- local output = vim.fn.systemlist(cmd)
- assert.are.equal(0, vim.v.shell_error, table.concat(output, '\n'))
- return output
-end
-
-local function create_repo()
- local repo_root = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo_root, 'p')
- test_repos[#test_repos + 1] = repo_root
-
- vim.fn.systemlist({ 'git', 'init', '-q', repo_root })
- assert.are.equal(0, vim.v.shell_error)
- git_cmd(repo_root, { 'config', 'user.email', 'test@example.com' })
- git_cmd(repo_root, { 'config', 'user.name', 'Test' })
- vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', 'file.txt' })
- git_cmd(repo_root, { 'commit', '-qm', 'initial' })
-
- return repo_root
-end
-
-local function write_binary_file(path, text)
- vim.fn.system({ 'sh', '-c', 'printf "$1" > "$2"', 'sh', text, path })
- assert.are.equal(0, vim.v.shell_error)
-end
-
-local function mock_git_content()
- saved_git.get_file_content = git.get_file_content
- saved_git.get_index_content = git.get_index_content
- saved_git.get_working_content = git.get_working_content
-
- git.get_file_content = function(rev, filepath)
- assert.are.equal('/tmp/repo/foo.lua', filepath)
- if rev == 'HEAD' then
- return { 'return "head"' }
- end
- if rev == 'HEAD~1' then
- return { 'return "older"' }
- end
- if rev == 'feature' then
- return { 'return "feature"' }
- end
- return nil, 'unknown rev'
- end
-
- git.get_index_content = function(filepath)
- assert.are.equal('/tmp/repo/foo.lua', filepath)
- return { 'return "index"' }
- end
-
- git.get_working_content = function(filepath)
- assert.are.equal('/tmp/repo/foo.lua', filepath)
- return { 'return "worktree"' }
- end
-end
-
-local function restore_mocks()
- for k, v in pairs(saved_git) do
- git[k] = v
- end
- saved_git = {}
- for _, repo_root in ipairs(test_repos) do
- vim.fn.delete(repo_root, 'rf')
- end
- test_repos = {}
-end
-
-local function render_text(spec)
- local lines, err = render.file(spec, '/tmp/repo')
- assert.is_nil(err)
- assert.is_not_nil(lines)
- return table.concat(lines, '\n')
-end
-
-describe('diffs.render', function()
- after_each(function()
- restore_mocks()
- vim.o.diffopt = original_diffopt
- end)
-
- it('renders index to worktree edges separately from staged changes', function()
- mock_git_content()
-
- local text = render_text(diffspec.index_to_worktree('foo.lua'))
-
- assert.is_true(text:find('-return "index"', 1, true) ~= nil)
- assert.is_true(text:find('+return "worktree"', 1, true) ~= nil)
- assert.is_false(text:find('return "head"', 1, true) ~= nil)
- end)
-
- it('renders HEAD to index edges separately from unstaged changes', function()
- mock_git_content()
-
- local text = render_text(diffspec.head_to_index('foo.lua'))
-
- assert.is_true(text:find('-return "head"', 1, true) ~= nil)
- assert.is_true(text:find('+return "index"', 1, true) ~= nil)
- assert.is_false(text:find('return "worktree"', 1, true) ~= nil)
- end)
-
- it('renders arbitrary tree to index edges', function()
- mock_git_content()
-
- local text = render_text(diffspec.file(diffspec.tree('HEAD~1'), diffspec.index(), 'foo.lua'))
-
- assert.is_true(text:find('-return "older"', 1, true) ~= nil)
- assert.is_true(text:find('+return "index"', 1, true) ~= nil)
- assert.is_false(text:find('return "worktree"', 1, true) ~= nil)
- end)
-
- it('renders revision to worktree edges', function()
- mock_git_content()
-
- local text = render_text(diffspec.rev_to_worktree('HEAD~1', 'foo.lua'))
-
- assert.is_true(text:find('-return "older"', 1, true) ~= nil)
- assert.is_true(text:find('+return "worktree"', 1, true) ~= nil)
- end)
-
- it('renders read-only revision to revision edges', function()
- local calls = {}
- mock_git_content()
- git.get_file_content = function(rev, filepath)
- table.insert(calls, { rev, filepath })
- if rev == 'HEAD~1' then
- return { 'return "older"' }
- end
- if rev == 'feature' then
- return { 'return "feature"' }
- end
- return nil, 'unknown rev'
- end
-
- local text = render_text(diffspec.rev_to_rev('HEAD~1', 'feature', 'foo.lua'))
-
- assert.are.same({
- { 'HEAD~1', '/tmp/repo/foo.lua' },
- { 'feature', '/tmp/repo/foo.lua' },
- }, calls)
- assert.is_true(text:find('-return "older"', 1, true) ~= nil)
- assert.is_true(text:find('+return "feature"', 1, true) ~= nil)
- end)
-
- it('can render against current unsaved worktree lines', function()
- mock_git_content()
-
- local lines, err = render.file(diffspec.rev_to_worktree('HEAD', 'foo.lua'), '/tmp/repo', {
- worktree_lines = { 'return "buffer"' },
- })
-
- assert.is_nil(err)
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('-return "head"', 1, true) ~= nil)
- assert.is_true(text:find('+return "buffer"', 1, true) ~= nil)
- assert.is_false(text:find('return "worktree"', 1, true) ~= nil)
- end)
-
- it('renders untracked files as index to worktree additions', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'new file' }, repo_root .. '/new.txt')
-
- local lines, err = render.file(diffspec.index_to_worktree('new.txt'), repo_root)
-
- assert.is_nil(err)
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('new file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('--- /dev/null', 1, true) ~= nil)
- assert.is_true(text:find('+new file', 1, true) ~= nil)
- end)
-
- it('renders staged deletions as HEAD to index deletions', function()
- local repo_root = create_repo()
- vim.fn.delete(repo_root .. '/file.txt')
- git_cmd(repo_root, { 'add', '-u', 'file.txt' })
-
- local lines, err = render.file(diffspec.head_to_index('file.txt'), repo_root)
-
- assert.is_nil(err)
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('deleted file mode 100644', 1, true) ~= nil)
- assert.is_true(text:find('+++ /dev/null', 1, true) ~= nil)
- assert.is_true(text:find('-line 1', 1, true) ~= nil)
- end)
-
- it('renders clean index to worktree edges as empty diffs', function()
- local repo_root = create_repo()
-
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(err)
- assert.are.same({}, lines)
- end)
-
- it('treats a whitespace-only change as no diff when iwhiteall is set', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1 ', 'line 2' }, repo_root .. '/file.txt')
-
- vim.o.diffopt = 'internal,filler,iwhiteall'
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(err)
- assert.are.same({}, lines)
- end)
-
- it('treats a blank-line-only change as no diff when iblank is set', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', '', 'line 2' }, repo_root .. '/file.txt')
-
- vim.o.diffopt = 'internal,filler,iblank'
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(err)
- assert.are.same({}, lines)
- end)
-
- it('renders clean HEAD to index edges as empty diffs', function()
- local repo_root = create_repo()
-
- local lines, err = render.file(diffspec.head_to_index('file.txt'), repo_root)
-
- assert.is_nil(err)
- assert.are.same({}, lines)
- end)
-
- it('preserves real trailing blank line changes', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2', '' }, repo_root .. '/file.txt')
-
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(err)
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('\n+\n', 1, true) ~= nil)
- end)
-
- it('renders final-newline-only changes with no-newline metadata', function()
- local repo_root = create_repo()
- vim.fn.writefile({ 'line 1', 'line 2' }, repo_root .. '/file.txt', 'b')
-
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(err)
- local text = table.concat(lines, '\n')
- assert.is_true(text:find('-line 2', 1, true) ~= nil)
- assert.is_true(text:find('+line 2', 1, true) ~= nil)
- assert.is_true(text:find('\\ No newline at end of file', 1, true) ~= nil)
- end)
-
- it('rejects same-path rename projections before rendering actionable hunks', function()
- local repo_root = create_repo()
- git_cmd(repo_root, { 'mv', 'file.txt', 'renamed.txt' })
-
- local lines, err = render.file(diffspec.head_to_index('renamed.txt'), repo_root)
-
- assert.is_nil(lines)
- assert.are.equal('diff does not support rename or copy changes', err)
- end)
-
- it('rejects mode-only changes because there are no text hunks to apply', function()
- local repo_root = create_repo()
- vim.fn.setfperm(repo_root .. '/file.txt', 'rwxr-xr-x')
-
- local lines, err = render.file(diffspec.index_to_worktree('file.txt'), repo_root)
-
- assert.is_nil(lines)
- assert.are.equal('diff does not support mode-only changes', err)
- end)
-
- it('rejects binary files before treating bytes as text hunks', function()
- local repo_root = create_repo()
- write_binary_file(repo_root .. '/bin.dat', 'binary\\000old')
- git_cmd(repo_root, { 'add', 'bin.dat' })
- git_cmd(repo_root, { 'commit', '-qm', 'binary' })
- write_binary_file(repo_root .. '/bin.dat', 'binary\\000new')
-
- local lines, err = render.file(diffspec.index_to_worktree('bin.dat'), repo_root)
-
- assert.is_nil(lines)
- assert.are.equal('diff does not support binary files', err)
- end)
-
- it('rejects submodule gitlinks before rendering pseudo text hunks', function()
- local repo_root = create_repo()
- git_cmd(repo_root, {
- 'update-index',
- '--add',
- '--cacheinfo',
- '160000,0123456789012345678901234567890123456789,submodule',
- })
-
- local lines, err = render.file(diffspec.head_to_index('submodule'), repo_root)
-
- assert.is_nil(lines)
- assert.are.equal('diff does not support submodule changes', err)
- end)
-end)
diff --git a/spec/review_spec.lua b/spec/review_spec.lua
deleted file mode 100644
index e2a5e73c..00000000
--- a/spec/review_spec.lua
+++ /dev/null
@@ -1,177 +0,0 @@
-require('spec.helpers')
-
-local render = require('diffs.render')
-local review = require('diffs.review')
-
-local repo
-local saved_diffopt
-
-local function git_result(args, opts)
- local cmd = { 'git', '-C', repo }
- vim.list_extend(cmd, args)
- return vim.system(cmd, vim.tbl_extend('force', { text = true }, opts or {})):wait()
-end
-
-local function git(args, opts)
- local result = git_result(args, opts)
- assert.are.equal(0, result.code, result.stderr)
- return vim.trim(result.stdout)
-end
-
-local function tree(text)
- local blob = git({ 'hash-object', '-w', '--stdin' }, { stdin = text .. '\n' })
- return git({ 'mktree' }, { stdin = '100644 blob ' .. blob .. '\tfile.txt\n' })
-end
-
-local function commit(name, tree_id, parents, timestamp)
- local args = { 'commit-tree', tree_id, '-m', name }
- for _, parent in ipairs(parents) do
- vim.list_extend(args, { '-p', parent })
- end
- local date = tostring(timestamp) .. ' +0000'
- local oid = git(args, { env = { GIT_AUTHOR_DATE = date, GIT_COMMITTER_DATE = date } })
- git({ 'update-ref', 'refs/heads/' .. name, oid })
- return oid
-end
-
-local function raw_diff(expression)
- return git_result({
- 'diff',
- '--no-ext-diff',
- '--no-color',
- '--src-prefix=a/',
- '--dst-prefix=b/',
- '--end-of-options',
- expression,
- '--',
- })
-end
-
-local function run_review(expression)
- local spec, parse_err = review.parse_arg(expression)
- assert.is_nil(parse_err)
- local normalized, normalize_err = review.normalize(spec, repo)
- assert.is_nil(normalize_err)
- local lines, err = review.run(normalized, {
- replace_combined_diffs = function(result)
- return result
- end,
- })
- return lines, err, spec
-end
-
-describe('review Git revisions', function()
- before_each(function()
- saved_diffopt = vim.o.diffopt
- vim.o.diffopt = 'internal,filler'
- repo = vim.fn.resolve(vim.fn.tempname())
- vim.fn.mkdir(repo, 'p')
- git({ 'init', '-q', '-b', 'main' })
- git({ 'config', 'user.email', 'test@example.com' })
- git({ 'config', 'user.name', 'Test' })
- git({ 'config', 'commit.gpgsign', 'false' })
- local base = commit('base', tree('base'), {}, 1000000000)
- commit('main', tree('main'), { base }, 1000000200)
- commit('feature', tree('feature'), { base }, 1000000100)
- end)
-
- after_each(function()
- vim.o.diffopt = saved_diffopt
- vim.fn.delete(repo, 'rf')
- end)
-
- for _, expression in ipairs({
- 'main..feature',
- 'main...feature',
- '..feature',
- 'feature..',
- '...feature',
- 'feature...',
- }) do
- it('uses Git output for ' .. expression, function()
- local expected = raw_diff(expression)
- assert.are.equal(0, expected.code, expected.stderr)
- local lines, err = run_review(expression)
- assert.is_nil(err)
- assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n')
- end)
- end
-
- it('accepts annotated tags and refs that are also filenames', function()
- git({ '-c', 'tag.gpgsign=false', 'tag', '-a', 'release', 'main', '-m', 'release' })
- vim.fn.writefile({ 'path named main' }, repo .. '/main')
- for _, expression in ipairs({ 'release..feature', 'main..feature' }) do
- local expected = raw_diff(expression)
- local lines, err = run_review(expression)
- assert.is_nil(err)
- assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n')
- end
- end)
-
- it('returns the Git error for a missing revision', function()
- local expected = raw_diff('main..missing')
- assert.are_not.equal(0, expected.code)
- local lines, err = run_review('main..missing')
- assert.is_nil(lines)
- assert.are.equal(vim.trim(expected.stderr), err)
- end)
-
- it('keeps all current-state sections for a single base or the default branch', function()
- git({ 'checkout', '-f', 'main' })
- vim.fn.writefile({ 'staged' }, repo .. '/file.txt')
- git({ 'add', 'file.txt' })
- vim.fn.writefile({ 'working' }, repo .. '/file.txt')
- vim.fn.writefile({ 'untracked' }, repo .. '/new.txt')
- git({ 'update-ref', 'refs/remotes/origin/main', git({ 'rev-parse', 'base' }) })
- git({ 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main' })
-
- for _, spec in ipairs({ { base = 'base' }, {} }) do
- local normalized, err = review.normalize(spec, repo)
- assert.is_nil(err)
- assert.is_nil(normalized.target)
- local lines, render_err, metadata = review.render(normalized, {
- replace_combined_diffs = function(result)
- return result
- end,
- })
- assert.is_nil(render_err)
- assert.are.same(
- { 'branch', 'staged', 'unstaged', 'untracked' },
- vim.tbl_map(function(section)
- return section.id
- end, metadata.sections)
- )
- for _, text in ipairs({ '+main', '+staged', '+working', '+untracked' }) do
- assert.is_true(vim.tbl_contains(lines, text), text)
- end
- end
- end)
-
- it('uses the same merge base for the review and split content', function()
- local base = git({ 'rev-parse', 'base' })
- local main_tree = git({ 'rev-parse', 'main^{tree}' })
- local feature_tree = git({ 'rev-parse', 'feature^{tree}' })
- local left = git({ 'rev-parse', 'main' })
- local chain = commit('chain1', feature_tree, { base }, 1000000100)
- chain = commit('chain2', feature_tree, { chain }, 1000000101)
- local right = commit('other', feature_tree, { chain }, 1000000150)
- commit('cross1', main_tree, { left, right }, 1000000300)
- commit('cross2', feature_tree, { right, left }, 1000000400)
- git({ 'commit-graph', 'write', '--reachable' })
-
- local expected = raw_diff('cross1...cross2')
- assert.are.equal(0, expected.code, expected.stderr)
- assert.is_truthy(expected.stderr:find('multiple merge bases', 1, true))
- local lines, err, spec = run_review('cross1...cross2')
- assert.is_nil(err)
- assert.are.equal(expected.stdout, table.concat(lines, '\n') .. '\n')
-
- local diff_spec, _, spec_err = review.diff_spec_for_file(spec, repo, 'file.txt')
- assert.is_nil(spec_err)
- local selected_base = expected.stderr:match('using (%x+)')
- assert.are.equal(selected_base, diff_spec.left.rev)
- local old_lines, read_err = render.read_endpoint(diff_spec.left, repo .. '/file.txt')
- assert.is_nil(read_err)
- assert.are.equal(git({ 'show', selected_base .. ':file.txt' }), table.concat(old_lines, '\n'))
- end)
-end)
diff --git a/spec/runtime_spec.lua b/spec/runtime_spec.lua
index bffaa10b..d5c16afa 100644
--- a/spec/runtime_spec.lua
+++ b/spec/runtime_spec.lua
@@ -228,46 +228,6 @@ describe('diffs.runtime', function()
close_window(non_diff_win)
close_window(diff_win)
end)
-
- it('neutralizes native diff highlighting on conflicted panes', function()
- local win, buf = create_diff_window()
- vim.api.nvim_buf_set_lines(buf, 0, -1, false, {
- '<<<<<<< HEAD',
- 'ours',
- '=======',
- 'theirs',
- '>>>>>>> feature',
- })
- runtime.attach_diff()
-
- local whl = vim.api.nvim_get_option_value('winhighlight', { win = win })
- assert.is_not_nil(whl:match('DiffText:DiffsDiffOff'))
- assert.is_nil(whl:match('DiffsDiffText'))
- assert.is_nil(whl:match('DiffsDiffAdd'))
-
- close_window(win)
- end)
-
- it('keeps the diff remap on plain panes next to a conflicted one', function()
- local conflict_win, conflict_buf = create_diff_window()
- vim.api.nvim_buf_set_lines(conflict_buf, 0, -1, false, {
- '<<<<<<< HEAD',
- 'x',
- '=======',
- 'y',
- '>>>>>>> feature',
- })
- local plain_win, _ = create_diff_window()
- runtime.attach_diff()
-
- local conflict_whl = vim.api.nvim_get_option_value('winhighlight', { win = conflict_win })
- local plain_whl = vim.api.nvim_get_option_value('winhighlight', { win = plain_win })
- assert.is_not_nil(conflict_whl:match('DiffAdd:DiffsDiffOff'))
- assert.is_not_nil(plain_whl:match('DiffAdd:DiffsDiffAdd'))
-
- close_window(conflict_win)
- close_window(plain_win)
- end)
end)
describe('detach_diff', function()
@@ -378,35 +338,10 @@ describe('diffs.runtime', function()
if opts.name == 'Normal' then
return { fg = 0xebdbb2, bg = 0x282828 }
end
- if opts.name == 'LineNr' then
- return { fg = 0x665c54 }
- end
- if opts.name == 'diffAdded' then
- return { fg = 0x80c080 }
- end
- if opts.name == 'diffRemoved' then
- return { fg = 0xc08080 }
- end
return saved_get_hl(ns, opts)
end
runtime._test.compute_highlight_groups()
assert.are.equal(0x282828, set_calls.DiffsClear.bg)
- assert.are.equal(0xebdbb2, set_calls.DiffsRail.fg)
- assert.are.equal(0x282828, set_calls.DiffsRail.bg)
- assert.is_true(set_calls.DiffsRail.nocombine)
- assert.are.equal(0x665c54, set_calls.DiffsRailNr.fg)
- assert.are.equal(0x282828, set_calls.DiffsRailNr.bg)
- assert.is_true(set_calls.DiffsRailNr.nocombine)
- assert.are.equal(0x80c080, set_calls.DiffsAddRailNr.fg)
- assert.is_number(set_calls.DiffsAddRailNr.bg)
- assert.is_true(set_calls.DiffsAddRailNr.nocombine)
- assert.are.equal(0xc08080, set_calls.DiffsDeleteRailNr.fg)
- assert.is_number(set_calls.DiffsDeleteRailNr.bg)
- assert.is_true(set_calls.DiffsDeleteRailNr.nocombine)
- assert.are.equal(0x80c080, set_calls.DiffsAddBar.fg)
- assert.is_number(set_calls.DiffsAddBar.bg)
- assert.are.equal(0xc08080, set_calls.DiffsDeleteBar.fg)
- assert.is_number(set_calls.DiffsDeleteBar.bg)
end)
it('blend_alpha controls DiffsAdd.bg intensity', function()
diff --git a/spec/split_align_spec.lua b/spec/split_align_spec.lua
deleted file mode 100644
index 93b636a8..00000000
--- a/spec/split_align_spec.lua
+++ /dev/null
@@ -1,109 +0,0 @@
-local align = require('diffs.split_align')
-
-local function hunk(index, old_start, new_start, lines)
- return {
- index = index,
- old_range = { start = old_start },
- new_range = { start = new_start },
- lines = lines,
- }
-end
-
-local function kinds(rows)
- local out = {}
- for i, r in ipairs(rows) do
- out[i] = r.kind
- end
- return out
-end
-
-describe('split_align.align', function()
- it('keeps both panes equal length and weaves unchanged context whole-file', function()
- local old_lines = { 'a', 'OLD', 'c' }
- local new_lines = { 'a', 'NEW', 'c' }
- local hunks = {
- hunk(1, 2, 2, {
- { kind = 'delete', old_lnum = 2 },
- { kind = 'add', new_lnum = 2 },
- }),
- }
-
- local r = align.align(old_lines, new_lines, hunks)
- assert.are.same({ 'a', 'OLD', 'c' }, r.left_lines)
- assert.are.same({ 'a', 'NEW', 'c' }, r.right_lines)
- assert.are.equal(#r.left_lines, #r.right_lines)
- assert.are.same({ 'context', 'delete', 'context' }, kinds(r.left_rows))
- assert.are.same({ 'context', 'add', 'context' }, kinds(r.right_rows))
- assert.are.equal(2, r.left_rows[2].old_lnum)
- assert.are.equal(2, r.right_rows[2].new_lnum)
- assert.are.equal(2, r.anchors[1])
- end)
-
- it('top-aligns an unequal change and pads the shorter side with fillers', function()
- local old_lines = { 'a', '-- old', '--- old', 'd' }
- local new_lines = { 'a', '++ new', '+++ new', 'N3', 'd' }
- local hunks = require('diffs.hunks').parse({
- '@@ -2,3 +2,4 @@',
- '--- old',
- '---- old',
- '+++ new',
- '++++ new',
- '+N3',
- ' d',
- })
-
- local r = align.align(old_lines, new_lines, hunks)
- assert.are.same({ 'a', '-- old', '--- old', '', 'd' }, r.left_lines)
- assert.are.same(new_lines, r.right_lines)
- assert.are.equal(#r.left_lines, #r.right_lines)
- assert.are.same({ 'context', 'delete', 'delete', 'filler', 'context' }, kinds(r.left_rows))
- assert.are.same({ 'context', 'add', 'add', 'add', 'context' }, kinds(r.right_rows))
- end)
-
- it('handles a pure-add file as all fillers on the left', function()
- local r = align.align({}, { 'x', 'y' }, {
- hunk(1, 1, 1, {
- { kind = 'add', new_lnum = 1 },
- { kind = 'add', new_lnum = 2 },
- }),
- })
- assert.are.same({ '', '' }, r.left_lines)
- assert.are.same({ 'x', 'y' }, r.right_lines)
- assert.are.same({ 'filler', 'filler' }, kinds(r.left_rows))
- assert.are.same({ 'add', 'add' }, kinds(r.right_rows))
- end)
-
- it('handles a pure-delete file as all fillers on the right', function()
- local r = align.align({ 'x', 'y' }, {}, {
- hunk(1, 1, 1, {
- { kind = 'delete', old_lnum = 1 },
- { kind = 'delete', old_lnum = 2 },
- }),
- })
- assert.are.same({ 'x', 'y' }, r.left_lines)
- assert.are.same({ '', '' }, r.right_lines)
- assert.are.same({ 'delete', 'delete' }, kinds(r.left_rows))
- assert.are.same({ 'filler', 'filler' }, kinds(r.right_rows))
- end)
-
- it('returns empty alignment for no hunks and empty files', function()
- local r = align.align({}, {}, {})
- assert.are.same({}, r.left_lines)
- assert.are.same({}, r.right_lines)
- end)
-
- it('weaves multiple hunks with unchanged regions between them', function()
- local old_lines = { 'a', 'OLD1', 'b', 'c', 'OLD2', 'e' }
- local new_lines = { 'a', 'NEW1', 'b', 'c', 'NEW2', 'e' }
- local hunks = {
- hunk(1, 2, 2, { { kind = 'delete', old_lnum = 2 }, { kind = 'add', new_lnum = 2 } }),
- hunk(2, 5, 5, { { kind = 'delete', old_lnum = 5 }, { kind = 'add', new_lnum = 5 } }),
- }
- local r = align.align(old_lines, new_lines, hunks)
- assert.are.same(old_lines, r.left_lines)
- assert.are.same(new_lines, r.right_lines)
- assert.are.equal(#r.left_lines, #r.right_lines)
- assert.are.equal(2, r.anchors[1])
- assert.are.equal(5, r.anchors[2])
- end)
-end)
diff --git a/spec/ux_spec.lua b/spec/ux_spec.lua
deleted file mode 100644
index 1c9921e0..00000000
--- a/spec/ux_spec.lua
+++ /dev/null
@@ -1,230 +0,0 @@
-local helpers = require('spec.helpers')
-
-local commands = require('diffs.commands')
-local diffspec = require('diffs.spec')
-local hunks = require('diffs.hunks')
-
-local counter = 0
-
-local function create_diffs_buffer(name)
- counter = counter + 1
- local bufnr = vim.api.nvim_create_buf(false, true)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
- 'diff --git a/file.lua b/file.lua',
- '--- a/file.lua',
- '+++ b/file.lua',
- '@@ -1,1 +1,2 @@',
- ' local x = 1',
- '+local y = 2',
- })
- vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
- vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = bufnr })
- vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
- vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
- vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
- vim.api.nvim_buf_set_name(bufnr, name or ('diffs://unstaged:file_' .. counter .. '.lua'))
- return bufnr
-end
-
-local function add_hunk_metadata(bufnr, diff_spec)
- local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
- vim.api.nvim_buf_set_var(
- bufnr,
- 'diffs_hunks',
- hunks.parse(lines, diff_spec or diffspec.index_to_worktree('file.lua'))
- )
- vim.api.nvim_buf_set_var(bufnr, 'diffs_source', { repo_root = '/tmp/repo' })
-end
-
-local function keymap_desc(bufnr, lhs, mode)
- for _, keymap in ipairs(vim.api.nvim_buf_get_keymap(bufnr, mode or 'n')) do
- if keymap.lhs == lhs then
- return keymap.desc
- end
- end
- return nil
-end
-
-describe('ux', function()
- describe('q keymap', function()
- it('does not replace an existing q keymap', function()
- local bufnr = create_diffs_buffer()
- vim.keymap.set('n', 'q', '', { buffer = bufnr, desc = 'user q' })
-
- commands.setup_diff_buf(bufnr)
-
- assert.are.equal('user q', keymap_desc(bufnr, 'q'))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('q closes the window', function()
- local bufnr = create_diffs_buffer()
- commands.setup_diff_buf(bufnr)
-
- vim.cmd('split')
- local win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(win, bufnr)
-
- local win_count_before = #vim.api.nvim_tabpage_list_wins(0)
-
- vim.api.nvim_buf_call(bufnr, function()
- vim.cmd('normal q')
- end)
-
- local win_count_after = #vim.api.nvim_tabpage_list_wins(0)
- assert.equals(win_count_before - 1, win_count_after)
- end)
- end)
-
- describe('hunk keymaps', function()
- it('sets hunk keymaps only on hunk-aware diff buffers', function()
- local bufnr = create_diffs_buffer()
- commands.setup_diff_buf(bufnr)
-
- assert.is_false(helpers.has_keymap(bufnr, ']c'))
- assert.is_false(helpers.has_keymap(bufnr, '[c'))
- assert.is_false(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, 'o'))
- assert.is_false(helpers.has_keymap(bufnr, 'do'))
- assert.is_false(helpers.has_keymap(bufnr, 'dp'))
- assert.is_false(helpers.has_keymap(bufnr, 'do', 'x'))
- assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x'))
-
- add_hunk_metadata(bufnr)
- commands.setup_diff_buf(bufnr)
-
- assert.is_true(helpers.has_keymap(bufnr, ']c'))
- assert.is_true(helpers.has_keymap(bufnr, '[c'))
- assert.is_true(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, 'o'))
- assert.is_false(helpers.has_keymap(bufnr, 'do'))
- assert.is_true(helpers.has_keymap(bufnr, 'dp'))
- assert.is_false(helpers.has_keymap(bufnr, 'do', 'x'))
- assert.is_true(helpers.has_keymap(bufnr, 'dp', 'x'))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('does not replace existing buffer-local hunk maps', function()
- local bufnr = create_diffs_buffer()
- add_hunk_metadata(bufnr)
- vim.keymap.set('n', 'o', '', { buffer = bufnr, desc = 'user open' })
- vim.keymap.set('n', ']c', '', { buffer = bufnr, desc = 'user next' })
- vim.keymap.set('n', 'do', '', { buffer = bufnr, desc = 'user obtain' })
- vim.keymap.set('n', 'dp', '', { buffer = bufnr, desc = 'user put' })
- vim.keymap.set('x', 'do', '', { buffer = bufnr, desc = 'user visual obtain' })
- vim.keymap.set('x', 'dp', '', { buffer = bufnr, desc = 'user visual put' })
-
- commands.setup_diff_buf(bufnr)
-
- assert.are.equal('user open', keymap_desc(bufnr, 'o'))
- assert.are.equal('user next', keymap_desc(bufnr, ']c'))
- assert.are.equal('user obtain', keymap_desc(bufnr, 'do'))
- assert.are.equal('user put', keymap_desc(bufnr, 'dp'))
- assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x'))
- assert.are.equal('user visual put', keymap_desc(bufnr, 'dp', 'x'))
- assert.are.equal('Open source file', keymap_desc(bufnr, ''))
- assert.are.equal('Previous diff hunk', keymap_desc(bufnr, '[c'))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('removes only plugin-owned hunk keymaps when metadata is removed', function()
- local bufnr = create_diffs_buffer()
- add_hunk_metadata(bufnr)
- vim.keymap.set('n', 'o', '', { buffer = bufnr, desc = 'user open' })
- vim.keymap.set('x', 'do', '', { buffer = bufnr, desc = 'user visual obtain' })
- commands.setup_diff_buf(bufnr)
-
- assert.are.equal('user open', keymap_desc(bufnr, 'o'))
- assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x'))
- assert.are.equal('Open source file', keymap_desc(bufnr, ''))
- assert.are.equal('Stage selected diff lines', keymap_desc(bufnr, 'dp', 'x'))
-
- vim.api.nvim_buf_del_var(bufnr, 'diffs_hunks')
- commands.setup_diff_buf(bufnr)
-
- assert.are.equal('user open', keymap_desc(bufnr, 'o'))
- assert.are.equal('user visual obtain', keymap_desc(bufnr, 'do', 'x'))
- assert.is_nil(keymap_desc(bufnr, ''))
- assert.is_nil(keymap_desc(bufnr, ']c'))
- assert.is_nil(keymap_desc(bufnr, '[c'))
- assert.is_nil(keymap_desc(bufnr, 'do'))
- assert.is_nil(keymap_desc(bufnr, 'dp'))
- assert.is_nil(keymap_desc(bufnr, 'dp', 'x'))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
-
- it('updates plugin-owned action keymaps when hunk actionability changes', function()
- local bufnr = create_diffs_buffer()
- add_hunk_metadata(bufnr, diffspec.index_to_worktree('file.lua'))
- commands.setup_diff_buf(bufnr)
-
- assert.is_true(helpers.has_keymap(bufnr, 'dp'))
- assert.is_false(helpers.has_keymap(bufnr, 'do'))
-
- add_hunk_metadata(bufnr, diffspec.head_to_index('file.lua'))
- commands.setup_diff_buf(bufnr)
-
- assert.is_false(helpers.has_keymap(bufnr, 'dp'))
- assert.is_false(helpers.has_keymap(bufnr, 'dp', 'x'))
- assert.is_true(helpers.has_keymap(bufnr, 'do'))
- assert.is_true(helpers.has_keymap(bufnr, 'do', 'x'))
- assert.is_true(helpers.has_keymap(bufnr, ']c'))
- assert.is_true(helpers.has_keymap(bufnr, '[c'))
- assert.is_true(helpers.has_keymap(bufnr, ''))
- assert.is_false(helpers.has_keymap(bufnr, 'o'))
- vim.api.nvim_buf_delete(bufnr, { force = true })
- end)
- end)
-
- describe('window reuse', function()
- it('returns nil when no diffs window exists', function()
- local win = commands.find_diffs_window()
- assert.is_nil(win)
- end)
-
- it('finds existing diffs:// window', function()
- local bufnr = create_diffs_buffer()
- vim.cmd('split')
- local expected_win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(expected_win, bufnr)
-
- local found = commands.find_diffs_window()
- assert.equals(expected_win, found)
-
- vim.api.nvim_win_close(expected_win, true)
- end)
-
- it('ignores non-diffs buffers', function()
- local normal_buf = helpers.create_buffer({ 'hello' })
- vim.cmd('split')
- local win = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(win, normal_buf)
-
- local found = commands.find_diffs_window()
- assert.is_nil(found)
-
- vim.api.nvim_win_close(win, true)
- helpers.delete_buffer(normal_buf)
- end)
-
- it('returns first diffs window when multiple exist', function()
- local buf1 = create_diffs_buffer()
- local buf2 = create_diffs_buffer()
-
- vim.cmd('split')
- local win1 = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(win1, buf1)
-
- vim.cmd('split')
- local win2 = vim.api.nvim_get_current_win()
- vim.api.nvim_win_set_buf(win2, buf2)
-
- local found = commands.find_diffs_window()
- assert.is_not_nil(found)
- assert.is_true(found == win1 or found == win2)
-
- vim.api.nvim_win_close(win1, true)
- vim.api.nvim_win_close(win2, true)
- end)
- end)
-end)