feat: add neogit support (#117)

## TODO

1. docs (vimdoc + readme) - this is a non-trivial feature
2. push luarocks version

## Problem

diffs.nvim only activates on `fugitive`, `git`, and `gitcommit`
filetypes.
Neogit uses its own custom filetypes (`NeogitStatus`,
`NeogitCommitView`,
`NeogitDiffView`) and doesn't set `b:git_dir`, so the plugin never
attaches
and repo root resolution fails for filetype detection within diff hunks.

## Solution

Two changes:

1. **`lua/diffs/init.lua`** — Add the three Neogit filetypes to the
default
`filetypes` list. The `FileType` autocmd in `plugin/diffs.lua` already
handles them correctly since the `is_fugitive_buffer` guard only applies
   to the `git` filetype.

2. **`lua/diffs/parser.lua`** — Add a CWD-based fallback in
`get_repo_root()`.
After the existing `b:diffs_repo_root` and `b:git_dir` checks, fall back
to
`vim.fn.getcwd()` via `git.get_repo_root()` (already cached). Without
this,
   the parser can't resolve filetypes for files in Neogit buffers.

Neogit's expanded diffs use standard unified diff format, so the parser
handles
them without modification.

Closes #110.
This commit is contained in:
Barrett Ruth 2026-02-14 17:12:01 -05:00 committed by GitHub
parent 5d3bbc3631
commit 3d640c207b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 314 additions and 56 deletions

View file

@ -2,14 +2,15 @@
**Syntax highlighting for diffs in Neovim**
Enhance `vim-fugitive` and Neovim's built-in diff mode with language-aware
syntax highlighting.
Enhance [vim-fugitive](https://github.com/tpope/vim-fugitive),
[Neogit](https://github.com/NeogitOrg/neogit), and Neovim's built-in diff mode
with language-aware syntax highlighting.
<video src="https://github.com/user-attachments/assets/24574916-ecb2-478e-a0ea-e4cdc971e310" width="100%" controls></video>
## Features
- Treesitter syntax highlighting in fugitive diffs and commit views
- Treesitter syntax highlighting in vim-fugitive, Neogit, and `diff` filetype
- Character-level intra-line diff highlighting (with optional
[vscode-diff](https://github.com/esmuellert/codediff.nvim) FFI backend for
word-level accuracy)
@ -37,6 +38,21 @@ luarocks install diffs.nvim
:help diffs.nvim
```
## FAQ
**Does diffs.nvim support vim-fugitive/Neogit?**
Yes. Enable it in your config:
```lua
vim.g.diffs = {
fugitive = true,
neogit = true,
}
```
See the documentation for more information.
## Known Limitations
- **Incomplete syntax context**: Treesitter parses each diff hunk in isolation.

View file

@ -7,8 +7,8 @@ License: MIT
INTRODUCTION *diffs.nvim*
diffs.nvim adds syntax highlighting to diff views. It overlays language-aware
highlights on top of default diff highlighting in vim-fugitive and Neovim's
built-in diff mode.
highlights on top of default diff highlighting in vim-fugitive, Neogit, and
Neovim's built-in diff mode.
Features: ~
- Syntax highlighting in |:Git| summary diffs and commit detail views
@ -53,7 +53,9 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
vim.g.diffs = {
debug = false,
hide_prefix = false,
filetypes = { 'fugitive', 'git', 'gitcommit' },
fugitive = false,
neogit = false,
extra_filetypes = {},
highlights = {
background = true,
gutter = true,
@ -83,10 +85,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
},
overrides = {},
},
fugitive = {
horizontal = 'du',
vertical = 'dU',
},
conflict = {
enabled = true,
disable_diagnostics = true,
@ -116,14 +114,36 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
is also enabled, the overlay inherits the line's
background color.
{filetypes} (table, default: {'fugitive','git','gitcommit'})
List of filetypes that trigger attachment. Add
`'diff'` to enable highlighting in plain `.diff`
and `.patch` files: >lua
{fugitive} (boolean|table, default: false)
Enable vim-fugitive integration. Accepts
`true`, `false`, or a table with sub-options
(see |diffs.FugitiveConfig|). When enabled,
the `fugitive` filetype is active and status
buffer keymaps are registered. >lua
vim.g.diffs = { fugitive = true }
vim.g.diffs = {
filetypes = {
'fugitive', 'git', 'gitcommit', 'diff',
},
fugitive = { horizontal = 'dd' },
}
<
{neogit} (boolean|table, default: false)
Enable Neogit integration. Accepts `true`,
`false`, or `{ enabled = false }`. When
enabled, `NeogitStatus`, `NeogitCommitView`,
and `NeogitDiffView` filetypes are active and
Neogit highlight overrides are applied. See
|diffs-neogit|. >lua
vim.g.diffs = { neogit = false }
<
{extra_filetypes} (table, default: {})
Additional filetypes to attach to, beyond the
built-in `git`, `gitcommit`, and any enabled
integration filetypes. Use this to enable
highlighting in plain `.diff` / `.patch`
files: >lua
vim.g.diffs = {
extra_filetypes = { 'diff' },
}
<
@ -131,10 +151,6 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
Controls which highlight features are enabled.
See |diffs.Highlights| for fields.
{fugitive} (table, default: see below)
Fugitive status buffer keymap options.
See |diffs.FugitiveConfig| for fields.
{conflict} (table, default: see below)
Inline merge conflict resolution options.
See |diffs.ConflictConfig| for fields.
@ -422,12 +438,20 @@ Configuration: ~
>lua
vim.g.diffs = {
fugitive = {
enabled = true, -- false to disable fugitive integration entirely
horizontal = 'du', -- keymap for horizontal split, false to disable
vertical = 'dU', -- keymap for vertical split, false to disable
},
}
<
Fields: ~
{enabled} (boolean, default: false)
Enable fugitive integration. When false, the
`fugitive` filetype is excluded and no status
buffer keymaps are registered. Shorthand:
`fugitive = false` is equivalent to
`fugitive = { enabled = false }`.
{horizontal} (string|false, default: 'du')
Keymap for unified diff in horizontal split.
Set to `false` to disable.
@ -579,6 +603,31 @@ The working file buffer is modified in place; save it when ready.
Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed
automatically after each resolution.
==============================================================================
NEOGIT *diffs-neogit*
diffs.nvim works with Neogit (https://github.com/NeogitOrg/neogit) out of
the box. Enable Neogit support in your config: >lua
vim.g.diffs = { neogit = true }
<
When a diff is expanded in a Neogit buffer (e.g., via TAB on a file in the
status view), diffs.nvim applies treesitter syntax highlighting and
intra-line diffs to the hunk lines, just as it does for fugitive.
Neogit highlight overrides: ~
On first attach to a Neogit buffer, diffs.nvim overrides Neogit's diff
highlight groups (`NeogitDiffAdd*`, `NeogitDiffDelete*`,
`NeogitDiffContext*`, `NeogitHunkHeader*`, `NeogitDiffHeader*`, etc.) by
setting them to empty (`{}`). This gives diffs.nvim sole control of diff
line visuals. The overrides are reapplied on `ColorScheme` since Neogit
re-defines its groups then. When `neogit = false`, no highlight overrides
are applied.
Deprecated: ~
The `filetypes` config key still works but is deprecated and will be
removed in 0.3.0. Use `fugitive`, `neogit`, and `extra_filetypes` instead.
==============================================================================
API *diffs-api*
@ -600,7 +649,7 @@ refresh({bufnr}) *diffs.refresh()*
IMPLEMENTATION *diffs-implementation*
Summary / commit detail views: ~
1. `FileType` autocmd for configured filetypes (see {filetypes}) triggers
1. `FileType` autocmd for computed filetypes (see |diffs-config|) triggers
|diffs.attach()|. For `git` buffers, only `fugitive://` URIs are attached.
2. The buffer is parsed to detect file headers (`M path/to/file`,
`diff --git a/... b/...`) and hunk headers (`@@ -10,3 +10,4 @@`)

View file

@ -443,18 +443,11 @@ function M.highlight_hunk(bufnr, ns, hunk, opts)
end
if opts.highlights.background and is_diff_line then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
end_row = buf_line + 1,
hl_group = line_hl,
hl_eol = true,
priority = p.line_bg,
})
local ext = { line_hl_group = line_hl, priority = p.line_bg }
if opts.highlights.gutter then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, {
number_hl_group = number_hl,
priority = p.line_bg,
})
ext.number_hl_group = number_hl
end
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, buf_line, 0, ext)
end
if is_marker and line_len > pw then

View file

@ -33,9 +33,13 @@
---@field priorities diffs.PrioritiesConfig
---@class diffs.FugitiveConfig
---@field enabled boolean
---@field horizontal string|false
---@field vertical string|false
---@class diffs.NeogitConfig
---@field enabled boolean
---@class diffs.ConflictKeymaps
---@field ours string|false
---@field theirs string|false
@ -56,9 +60,11 @@
---@class diffs.Config
---@field debug boolean|string
---@field hide_prefix boolean
---@field filetypes string[]
---@field filetypes? string[] @deprecated use fugitive, neogit, extra_filetypes
---@field extra_filetypes string[]
---@field highlights diffs.Highlights
---@field fugitive diffs.FugitiveConfig
---@field neogit diffs.NeogitConfig
---@field conflict diffs.ConflictConfig
---@class diffs
@ -108,7 +114,7 @@ end
local default_config = {
debug = false,
hide_prefix = false,
filetypes = { 'fugitive', 'git', 'gitcommit' },
extra_filetypes = {},
highlights = {
background = true,
gutter = true,
@ -137,9 +143,13 @@ local default_config = {
},
},
fugitive = {
enabled = false,
horizontal = 'du',
vertical = 'dU',
},
neogit = {
enabled = false,
},
conflict = {
enabled = true,
disable_diagnostics = true,
@ -188,6 +198,31 @@ function M.is_fugitive_buffer(bufnr)
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
end
---@param opts table
---@return string[]
function M.compute_filetypes(opts)
if opts.filetypes then
return opts.filetypes
end
local fts = { 'git', 'gitcommit' }
local fug = opts.fugitive
if fug == true or (type(fug) == 'table' and fug.enabled ~= false) then
table.insert(fts, 'fugitive')
end
local neo = opts.neogit
if neo == true or (type(neo) == 'table' and neo.enabled ~= false) then
table.insert(fts, 'NeogitStatus')
table.insert(fts, 'NeogitCommitView')
table.insert(fts, 'NeogitDiffView')
end
if type(opts.extra_filetypes) == 'table' then
for _, ft in ipairs(opts.extra_filetypes) do
table.insert(fts, ft)
end
end
return fts
end
local dbg = log.dbg
---@param bufnr integer
@ -387,6 +422,34 @@ local function compute_highlight_groups()
end
end
local neogit_attached = false
local neogit_hl_groups = {
'NeogitDiffAdd',
'NeogitDiffAddCursor',
'NeogitDiffAddHighlight',
'NeogitDiffDelete',
'NeogitDiffDeleteCursor',
'NeogitDiffDeleteHighlight',
'NeogitDiffContext',
'NeogitDiffContextCursor',
'NeogitDiffContextHighlight',
'NeogitDiffHeader',
'NeogitDiffHeaderHighlight',
'NeogitHunkHeader',
'NeogitHunkHeaderCursor',
'NeogitHunkHeaderHighlight',
'NeogitHunkMergeHeader',
'NeogitHunkMergeHeaderCursor',
'NeogitHunkMergeHeaderHighlight',
}
local function override_neogit_highlights()
for _, name in ipairs(neogit_hl_groups) do
vim.api.nvim_set_hl(0, name, {})
end
end
local function init()
if initialized then
return
@ -395,6 +458,35 @@ local function init()
local opts = vim.g.diffs or {}
if opts.filetypes then
vim.deprecate(
'vim.g.diffs.filetypes',
'fugitive, neogit, and extra_filetypes',
'0.3.0',
'diffs.nvim'
)
end
if opts.fugitive == true then
opts.fugitive = { enabled = true }
elseif opts.fugitive == false then
opts.fugitive = { enabled = false }
elseif opts.fugitive == nil then
opts.fugitive = nil
elseif type(opts.fugitive) == 'table' and opts.fugitive.enabled == nil then
opts.fugitive.enabled = true
end
if opts.neogit == true then
opts.neogit = { enabled = true }
elseif opts.neogit == false then
opts.neogit = { enabled = false }
elseif opts.neogit == nil then
opts.neogit = nil
elseif type(opts.neogit) == 'table' and opts.neogit.enabled == nil then
opts.neogit.enabled = true
end
vim.validate({
debug = {
opts.debug,
@ -404,7 +496,9 @@ local function init()
'boolean or string (file path)',
},
hide_prefix = { opts.hide_prefix, 'boolean', true },
filetypes = { opts.filetypes, 'table', true },
fugitive = { opts.fugitive, 'table', true },
neogit = { opts.neogit, 'table', true },
extra_filetypes = { opts.extra_filetypes, 'table', true },
highlights = { opts.highlights, 'table', true },
})
@ -472,23 +566,30 @@ local function init()
if opts.fugitive then
vim.validate({
['fugitive.enabled'] = { opts.fugitive.enabled, 'boolean', true },
['fugitive.horizontal'] = {
opts.fugitive.horizontal,
function(v)
return v == false or type(v) == 'string'
return v == nil or v == false or type(v) == 'string'
end,
'string or false',
},
['fugitive.vertical'] = {
opts.fugitive.vertical,
function(v)
return v == false or type(v) == 'string'
return v == nil or v == false or type(v) == 'string'
end,
'string or false',
},
})
end
if opts.neogit then
vim.validate({
['neogit.enabled'] = { opts.neogit.enabled, 'boolean', true },
})
end
if opts.conflict then
vim.validate({
['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true },
@ -583,6 +684,9 @@ local function init()
vim.api.nvim_create_autocmd('ColorScheme', {
callback = function()
compute_highlight_groups()
if neogit_attached then
vim.schedule(override_neogit_highlights)
end
for bufnr, _ in pairs(attached_buffers) do
invalidate_cache(bufnr)
end
@ -701,6 +805,11 @@ function M.attach(bufnr)
end
attached_buffers[bufnr] = true
if not neogit_attached and config.neogit.enabled and vim.bo[bufnr].filetype:match('^Neogit') then
neogit_attached = true
vim.schedule(override_neogit_highlights)
end
dbg('attaching to buffer %d', bufnr)
ensure_cache(bufnr)

View file

@ -110,7 +110,9 @@ local function get_repo_root(bufnr)
return vim.fn.fnamemodify(git_dir, ':h')
end
return nil
local cwd = vim.fn.getcwd()
local git = require('diffs.git')
return git.get_repo_root(cwd .. '/.')
end
---@param bufnr integer
@ -194,7 +196,13 @@ function M.parse_buffer(bufnr)
for i, line in ipairs(lines) do
local diff_git_file = line:match('^diff %-%-git a/.+ b/(.+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file
local neogit_file = line:match('^modified%s+(.+)$')
or line:match('^new file%s+(.+)$')
or line:match('^deleted%s+(.+)$')
or line:match('^renamed%s+(.+)$')
or line:match('^copied%s+(.+)$')
local bare_file = not hunk_start and line:match('^([^%s]+%.[^%s]+)$')
local filename = line:match('^[MADRCU%?!]%s+(.+)$') or diff_git_file or neogit_file or bare_file
if filename then
is_unified_diff = diff_git_file ~= nil
flush_hunk()

View file

@ -6,7 +6,7 @@ vim.g.loaded_diffs = 1
require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', {
pattern = (vim.g.diffs or {}).filetypes or { 'fugitive', 'git', 'gitcommit' },
pattern = require('diffs').compute_filetypes(vim.g.diffs or {}),
callback = function(args)
local diffs = require('diffs')
if args.match == 'git' and not diffs.is_fugitive_buffer(args.buf) then

View file

@ -287,7 +287,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -320,7 +320,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_delete = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsDelete' then
if mark[4] and mark[4].line_hl_group == 'DiffsDelete' then
has_diff_delete = true
break
end
@ -386,7 +386,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -500,7 +500,7 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local has_diff_add = false
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_group == 'DiffsAdd' then
if mark[4] and mark[4].line_hl_group == 'DiffsAdd' then
has_diff_add = true
break
end
@ -560,7 +560,7 @@ describe('highlight', function()
delete_buffer(bufnr)
end)
it('uses hl_group not line_hl_group for line backgrounds', function()
it('uses line_hl_group for line backgrounds', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
@ -582,17 +582,19 @@ describe('highlight', function()
)
local extmarks = get_extmarks(bufnr)
local found = false
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
assert.is_true(d.hl_eol == true)
assert.is_nil(d.line_hl_group)
if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
found = true
assert.is_nil(d.hl_eol)
end
end
assert.is_true(found)
delete_buffer(bufnr)
end)
it('hl_eol background extmarks are multiline so hl_eol takes effect', function()
it('line_hl_group background extmarks are single-line', function()
local bufnr = create_buffer({
'@@ -1,2 +1,1 @@',
'-local x = 1',
@ -616,8 +618,8 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and (d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete') then
assert.is_true(d.end_row > mark[2])
if d and (d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete') then
assert.is_nil(d.end_row)
end
end
delete_buffer(bufnr)
@ -771,7 +773,7 @@ describe('highlight', function()
if d then
if d.hl_group == 'DiffsClear' then
table.insert(priorities.clear, d.priority)
elseif d.hl_group == 'DiffsAdd' or d.hl_group == 'DiffsDelete' then
elseif d.line_hl_group == 'DiffsAdd' or d.line_hl_group == 'DiffsDelete' then
table.insert(priorities.line_bg, d.priority)
elseif d.hl_group == 'DiffsAddText' or d.hl_group == 'DiffsDeleteText' then
table.insert(priorities.char_bg, d.priority)
@ -871,8 +873,8 @@ describe('highlight', function()
local extmarks = get_extmarks(bufnr)
local line_bgs = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].hl_eol then
line_bgs[mark[2]] = mark[4].hl_group
if mark[4] and mark[4].line_hl_group then
line_bgs[mark[2]] = mark[4].line_hl_group
end
end
assert.is_nil(line_bgs[1])
@ -1063,8 +1065,8 @@ describe('highlight', function()
local marker_text = {}
for _, mark in ipairs(extmarks) do
local d = mark[4]
if d and d.hl_eol then
line_bgs[mark[2]] = d.hl_group
if d and d.line_hl_group then
line_bgs[mark[2]] = d.line_hl_group
end
if d and d.number_hl_group then
gutter_hls[mark[2]] = d.number_hl_group

View file

@ -587,5 +587,86 @@ describe('parser', function()
assert.are.equal('/tmp/test-repo', hunks[1].repo_root)
delete_buffer(bufnr)
end)
it('detects neogit modified prefix', function()
local bufnr = create_buffer({
'modified hello.lua',
'@@ -1,2 +1,3 @@',
' local M = {}',
'+local x = 1',
' return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(3, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects neogit new file prefix', function()
local bufnr = create_buffer({
'new file hello.lua',
'@@ -0,0 +1,2 @@',
'+local M = {}',
'+return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(2, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects neogit deleted prefix', function()
local bufnr = create_buffer({
'deleted hello.lua',
'@@ -1,2 +0,0 @@',
'-local M = {}',
'-return M',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('hello.lua', hunks[1].filename)
assert.are.equal('lua', hunks[1].ft)
assert.are.equal(2, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('detects bare filename for untracked files', function()
local bufnr = create_buffer({
'newfile.rs',
'@@ -0,0 +1,3 @@',
'+fn main() {',
'+ println!("hello");',
'+}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('newfile.rs', hunks[1].filename)
assert.are.equal(3, #hunks[1].lines)
delete_buffer(bufnr)
end)
it('does not match section headers as bare filenames', function()
local bufnr = create_buffer({
'Untracked files (1)',
'newfile.rs',
'@@ -0,0 +1,3 @@',
'+fn main() {',
'+ println!("hello");',
'+}',
})
local hunks = parser.parse_buffer(bufnr)
assert.are.equal(1, #hunks)
assert.are.equal('newfile.rs', hunks[1].filename)
delete_buffer(bufnr)
end)
end)
end)