diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
index 4a8ef2bba5..a05f22d9b3 100644
--- a/runtime/doc/news.txt
+++ b/runtime/doc/news.txt
@@ -203,6 +203,7 @@ EDITOR
• |:Undotree| for visually navigating the |undo-tree|
• |:wall| permits a |++p| option for creating parent directories when writing
changed buffers.
+• The |:DiffTool| command compares directories (and files).
EVENTS
diff --git a/runtime/doc/plugins.txt b/runtime/doc/plugins.txt
index 1fd46e9725..34eea2a076 100644
--- a/runtime/doc/plugins.txt
+++ b/runtime/doc/plugins.txt
@@ -18,6 +18,7 @@ loaded by default while others are not loaded until requested by |:packadd|.
Standard plugins ~
*standard-plugin-list*
Help-link Loaded Short description ~
+|difftool| No Compares two directories or files side-by-side
|editorconfig| Yes Detect and interpret editorconfig
|ft-shada| Yes Allows editing binary |shada| files
|man.lua| Yes View manpages in Nvim
@@ -40,6 +41,46 @@ Help-link Loaded Short description ~
|tohtml| Yes Convert buffer to html, syntax included
|undotree| No Interactive textual undotree
+==============================================================================
+Builtin plugin: difftool *difftool*
+
+
+:DiffTool {left} {right} *:DiffTool*
+Compares two directories or files side-by-side.
+Supports directory diffing, rename detection, and highlights changes
+in quickfix list.
+
+The plugin is not loaded by default; use `:packadd nvim.difftool` before
+invoking `:DiffTool`.
+
+Example `git difftool -d` integration using `DiffTool` command: >ini
+ [difftool "nvim_difftool"]
+ cmd = nvim -c "packadd nvim.difftool" -c "DiffTool $LOCAL $REMOTE"
+ [diff]
+ tool = nvim_difftool
+<
+
+
+open({left}, {right}, {opt}) *difftool.open()*
+ Diff two files or directories
+
+ Parameters: ~
+ • {left} (`string`)
+ • {right} (`string`)
+ • {opt} (`table?`)
+ • {rename.detect} (`boolean`, default: `false`) Whether to
+ detect renames
+ • {rename.similarity} (`number`, default: `0.5`) Minimum
+ similarity for rename detection (0 to 1)
+ • {rename.chunk_size} (`number`, default: `4096`) Maximum
+ chunk size to read from files for similarity calculation
+ • {method} (`'auto'|'builtin'|'diffr'`, default: `auto`) Diff
+ method to use
+ • {ignore} (`string[]`, default: `{}`) List of file patterns
+ to ignore (for example: `'.git', '*.log'`)
+ • {rename} (`table`) Controls rename detection
+
+
==============================================================================
Builtin plugin: editorconfig *editorconfig*
diff --git a/runtime/lua/vim/_core/util.lua b/runtime/lua/vim/_core/util.lua
new file mode 100644
index 0000000000..561118369e
--- /dev/null
+++ b/runtime/lua/vim/_core/util.lua
@@ -0,0 +1,36 @@
+local M = {}
+
+--- Edit a file in a specific window
+--- @param winnr number
+--- @param file string
+--- @return number buffer number of the edited buffer
+M.edit_in = function(winnr, file)
+ return vim.api.nvim_win_call(winnr, function()
+ local current = vim.fs.abspath(vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(winnr)))
+
+ -- Check if the current buffer is already the target file
+ if current == (file and vim.fs.abspath(file) or '') then
+ return vim.api.nvim_get_current_buf()
+ end
+
+ -- Read the file into the buffer
+ vim.cmd.edit(vim.fn.fnameescape(file))
+ return vim.api.nvim_get_current_buf()
+ end)
+end
+
+--- Read a chunk of data from a file
+--- @param file string
+--- @param size number
+--- @return string? chunk or nil on error
+function M.read_chunk(file, size)
+ local fd = io.open(file, 'rb')
+ if not fd then
+ return nil
+ end
+ local chunk = fd:read(size)
+ fd:close()
+ return tostring(chunk)
+end
+
+return M
diff --git a/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua b/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua
new file mode 100644
index 0000000000..269eabcca4
--- /dev/null
+++ b/runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua
@@ -0,0 +1,494 @@
+--- @brief
+---
help
+---:DiffTool {left} {right} *:DiffTool*
+---Compares two directories or files side-by-side.
+---Supports directory diffing, rename detection, and highlights changes
+---in quickfix list.
+---
+---
+--- The plugin is not loaded by default; use `:packadd nvim.difftool` before invoking `:DiffTool`.
+---
+--- Example `git difftool -d` integration using `DiffTool` command:
+---
+--- ```ini
+--- [difftool "nvim_difftool"]
+--- cmd = nvim -c "packadd nvim.difftool" -c "DiffTool $LOCAL $REMOTE"
+--- [diff]
+--- tool = nvim_difftool
+--- ```
+
+local highlight_groups = {
+ A = 'DiffAdd',
+ D = 'DiffDelete',
+ M = 'DiffText',
+ R = 'DiffChange',
+}
+
+local layout = {
+ group = nil,
+ left_win = nil,
+ right_win = nil,
+}
+
+local util = require('vim._core.util')
+
+--- Set up a consistent layout with two diff windows
+--- @param with_qf boolean whether to open the quickfix window
+local function setup_layout(with_qf)
+ local wins = vim.api.nvim_tabpage_list_wins(0)
+ local left_valid = layout.left_win and vim.api.nvim_win_is_valid(layout.left_win)
+ local right_valid = layout.right_win and vim.api.nvim_win_is_valid(layout.right_win)
+ local wins_passed = left_valid and right_valid
+
+ local qf_passed = not with_qf
+ if not qf_passed and wins_passed then
+ for _, win in ipairs(wins) do
+ local buf = vim.api.nvim_win_get_buf(win)
+ local ft = vim.bo[buf].filetype
+ if ft == 'qf' then
+ qf_passed = true
+ break
+ end
+ end
+ end
+
+ if wins_passed and qf_passed then
+ return false
+ end
+
+ vim.cmd.only()
+ layout.left_win = vim.api.nvim_get_current_win()
+ vim.cmd.vsplit()
+ layout.right_win = vim.api.nvim_get_current_win()
+
+ if with_qf then
+ vim.cmd('botright copen')
+ end
+ vim.api.nvim_set_current_win(layout.right_win)
+end
+
+--- Diff two files
+--- @param left_file string
+--- @param right_file string
+--- @param with_qf boolean? whether to open the quickfix window
+local function diff_files(left_file, right_file, with_qf)
+ setup_layout(with_qf or false)
+
+ local left_buf = util.edit_in(layout.left_win, left_file)
+ local right_buf = util.edit_in(layout.right_win, right_file)
+
+ -- When one of the windows is closed, clean up the layout
+ vim.api.nvim_create_autocmd('WinClosed', {
+ group = layout.group,
+ buffer = left_buf,
+ callback = function()
+ if layout.group and layout.left_win then
+ vim.api.nvim_del_augroup_by_id(layout.group)
+ layout.left_win = nil
+ layout.group = nil
+ vim.fn.setqflist({})
+ vim.cmd.cclose()
+ end
+ end,
+ })
+ vim.api.nvim_create_autocmd('WinClosed', {
+ group = layout.group,
+ buffer = right_buf,
+ callback = function()
+ if layout.group and layout.right_win then
+ vim.api.nvim_del_augroup_by_id(layout.group)
+ layout.right_win = nil
+ layout.group = nil
+ vim.fn.setqflist({})
+ vim.cmd.cclose()
+ end
+ end,
+ })
+
+ vim.cmd('diffoff!')
+ vim.api.nvim_win_call(layout.left_win, vim.cmd.diffthis)
+ vim.api.nvim_win_call(layout.right_win, vim.cmd.diffthis)
+end
+
+--- Diff two directories using external `diff` command
+--- @param left_dir string
+--- @param right_dir string
+--- @param opt difftool.opt
+--- @return table[] list of quickfix entries
+local function diff_dirs_diffr(left_dir, right_dir, opt)
+ local args = { 'diff', '-qrN' }
+ for _, pattern in ipairs(opt.ignore) do
+ table.insert(args, '-x')
+ table.insert(args, pattern)
+ end
+ table.insert(args, left_dir)
+ table.insert(args, right_dir)
+
+ local output = vim.fn.system(args)
+ local lines = vim.split(output, '\n')
+ local qf_entries = {}
+
+ for _, line in ipairs(lines) do
+ local modified_left, modified_right = line:match('^Files (.+) and (.+) differ$')
+ if modified_left and modified_right then
+ local left_exists = vim.fn.filereadable(modified_left) == 1
+ local right_exists = vim.fn.filereadable(modified_right) == 1
+ local status = '?'
+ if left_exists and right_exists then
+ status = 'M'
+ elseif left_exists then
+ status = 'D'
+ elseif right_exists then
+ status = 'A'
+ end
+ table.insert(qf_entries, {
+ filename = modified_right,
+ text = status,
+ user_data = {
+ diff = true,
+ rel = vim.fs.relpath(left_dir, modified_left),
+ left = vim.fs.abspath(modified_left),
+ right = vim.fs.abspath(modified_right),
+ },
+ })
+ end
+ end
+
+ return qf_entries
+end
+
+--- Diff two directories using built-in Lua implementation
+--- @param left_dir string
+--- @param right_dir string
+--- @param opt difftool.opt
+--- @return table[] list of quickfix entries
+local function diff_dirs_builtin(left_dir, right_dir, opt)
+ --- @param rel_path string?
+ --- @param ignore string[]
+ --- @return boolean
+ local function is_ignored(rel_path, ignore)
+ if not rel_path then
+ return false
+ end
+ for _, pat in ipairs(ignore) do
+ if vim.fn.match(rel_path, pat) >= 0 then
+ return true
+ end
+ end
+ return false
+ end
+
+ --- @param file1 string
+ --- @param file2 string
+ --- @param chunk_size number
+ --- @param chunk_cache table
+ --- @return number similarity ratio (0 to 1)
+ local function calculate_similarity(file1, file2, chunk_size, chunk_cache)
+ -- Get or read chunk for file1
+ local chunk1 = chunk_cache[file1]
+ if not chunk1 then
+ chunk1 = util.read_chunk(file1, chunk_size)
+ chunk_cache[file1] = chunk1
+ end
+
+ -- Get or read chunk for file2
+ local chunk2 = chunk_cache[file2]
+ if not chunk2 then
+ chunk2 = util.read_chunk(file2, chunk_size)
+ chunk_cache[file2] = chunk2
+ end
+
+ if not chunk1 or not chunk2 then
+ return 0
+ end
+ if chunk1 == chunk2 then
+ return 1
+ end
+ local matches = 0
+ local len = math.min(#chunk1, #chunk2)
+ for i = 1, len do
+ if chunk1:sub(i, i) == chunk2:sub(i, i) then
+ matches = matches + 1
+ end
+ end
+ return matches / len
+ end
+
+ -- Create a map of all relative paths
+
+ --- @type table
+ local all_paths = {}
+ --- @type table
+ local left_only = {}
+ --- @type table
+ local right_only = {}
+
+ local function process_files_in_directory(dir_path, is_left)
+ local files = vim.fs.find(function(name, path)
+ local rel_path = vim.fs.relpath(dir_path, vim.fs.joinpath(path, name))
+ return not is_ignored(rel_path, opt.ignore)
+ end, { limit = math.huge, path = dir_path, follow = false })
+
+ for _, full_path in ipairs(files) do
+ local rel_path = vim.fs.relpath(dir_path, full_path)
+ if rel_path then
+ full_path = vim.fn.resolve(full_path)
+
+ if vim.fn.isdirectory(full_path) == 0 then
+ all_paths[rel_path] = all_paths[rel_path] or { left = nil, right = nil }
+
+ if is_left then
+ all_paths[rel_path].left = full_path
+ if not all_paths[rel_path].right then
+ left_only[rel_path] = full_path
+ end
+ else
+ all_paths[rel_path].right = full_path
+ if not all_paths[rel_path].left then
+ right_only[rel_path] = full_path
+ end
+ end
+ end
+ end
+ end
+ end
+
+ -- Process both directories
+ process_files_in_directory(left_dir, true)
+ process_files_in_directory(right_dir, false)
+
+ --- @type table
+ local renamed = {}
+ --- @type table
+ local chunk_cache = {}
+
+ -- Detect possible renames
+ if opt.rename.detect then
+ for left_rel, left_path in pairs(left_only) do
+ ---@type {similarity: number, path: string?, rel: string}
+ local best_match = { similarity = opt.rename.similarity, path = nil }
+
+ for right_rel, right_path in pairs(right_only) do
+ local similarity =
+ calculate_similarity(left_path, right_path, opt.rename.chunk_size, chunk_cache)
+
+ if similarity > best_match.similarity then
+ best_match = {
+ similarity = similarity,
+ path = right_path,
+ rel = right_rel,
+ }
+ end
+ end
+
+ if best_match.path and best_match.rel then
+ renamed[left_rel] = best_match.rel
+ all_paths[left_rel].right = best_match.path
+ all_paths[best_match.rel] = nil
+ left_only[left_rel] = nil
+ right_only[best_match.rel] = nil
+ end
+ end
+ end
+
+ local qf_entries = {}
+
+ -- Convert to quickfix entries
+ for rel_path, files in pairs(all_paths) do
+ local status = nil
+ if files.left and files.right then
+ --- @type number
+ local similarity
+ if opt.rename.detect then
+ similarity =
+ calculate_similarity(files.left, files.right, opt.rename.chunk_size, chunk_cache)
+ else
+ similarity = vim.fn.getfsize(files.left) == vim.fn.getfsize(files.right) and 1 or 0
+ end
+ if similarity < 1 then
+ status = renamed[rel_path] and 'R' or 'M'
+ end
+ elseif files.left then
+ status = 'D'
+ files.right = right_dir .. rel_path
+ elseif files.right then
+ status = 'A'
+ files.left = left_dir .. rel_path
+ end
+
+ if status then
+ table.insert(qf_entries, {
+ filename = files.right,
+ text = status,
+ user_data = {
+ diff = true,
+ rel = rel_path,
+ left = files.left,
+ right = files.right,
+ },
+ })
+ end
+ end
+
+ return qf_entries
+end
+
+--- Diff two directories
+--- @param left_dir string
+--- @param right_dir string
+--- @param opt difftool.opt
+local function diff_dirs(left_dir, right_dir, opt)
+ local method = opt.method
+ if method == 'auto' then
+ if not opt.rename.detect and vim.fn.executable('diff') == 1 then
+ method = 'diffr'
+ else
+ method = 'builtin'
+ end
+ end
+
+ --- @type table[]
+ local qf_entries
+ if method == 'diffr' then
+ qf_entries = diff_dirs_diffr(left_dir, right_dir, opt)
+ elseif method == 'builtin' then
+ qf_entries = diff_dirs_builtin(left_dir, right_dir, opt)
+ else
+ vim.notify('Unknown diff method: ' .. method, vim.log.levels.ERROR)
+ return
+ end
+
+ -- Sort entries by filename for consistency
+ table.sort(qf_entries, function(a, b)
+ return a.user_data.rel < b.user_data.rel
+ end)
+
+ vim.fn.setqflist({}, 'r', {
+ nr = '$',
+ title = 'DiffTool',
+ items = qf_entries,
+ ---@param info {id: number, start_idx: number, end_idx: number}
+ quickfixtextfunc = function(info)
+ --- @type table[]
+ local items = vim.fn.getqflist({ id = info.id, items = 1 }).items
+ local out = {}
+ for item = info.start_idx, info.end_idx do
+ local entry = items[item]
+ table.insert(out, entry.text .. ' ' .. entry.user_data.rel)
+ end
+ return out
+ end,
+ })
+
+ setup_layout(true)
+ vim.cmd.cfirst()
+end
+
+local M = {}
+
+--- @class difftool.opt
+--- @inlinedoc
+---
+--- Diff method to use
+--- (default: `auto`)
+--- @field method 'auto'|'builtin'|'diffr'
+---
+--- List of file patterns to ignore (for example: `'.git', '*.log'`)
+--- (default: `{}`)
+--- @field ignore string[]
+---
+--- Rename detection options (supported only by `builtin` method)
+--- @field rename table Controls rename detection
+---
+--- - {rename.detect} (`boolean`, default: `false`) Whether to detect renames
+--- - {rename.similarity} (`number`, default: `0.5`) Minimum similarity for rename detection (0 to 1)
+--- - {rename.chunk_size} (`number`, default: `4096`) Maximum chunk size to read from files for similarity calculation
+
+--- Diff two files or directories
+--- @param left string
+--- @param right string
+--- @param opt? difftool.opt
+function M.open(left, right, opt)
+ if not left or not right then
+ vim.notify('Both arguments are required', vim.log.levels.ERROR)
+ return
+ end
+
+ local config = vim.tbl_deep_extend('force', {
+ method = 'auto',
+ ignore = {},
+ rename = {
+ detect = false,
+ similarity = 0.5,
+ chunk_size = 4096,
+ },
+ }, opt or {})
+
+ layout.group = vim.api.nvim_create_augroup('nvim.difftool.events', { clear = true })
+ local hl_id = vim.api.nvim_create_namespace('nvim.difftool.hl')
+
+ local function get_diff_entry()
+ --- @type {idx: number, items: table[], size: number}
+ local qf_info = vim.fn.getqflist({ idx = 0, items = 1, size = 1 })
+ if qf_info.size == 0 then
+ return false
+ end
+
+ local entry = qf_info.items[qf_info.idx]
+ if not entry or not entry.user_data or not entry.user_data.diff then
+ return nil
+ end
+
+ return entry
+ end
+
+ vim.api.nvim_create_autocmd('BufWinEnter', {
+ group = layout.group,
+ pattern = 'quickfix',
+ callback = function(args)
+ if not get_diff_entry() then
+ return
+ end
+
+ vim.api.nvim_buf_clear_namespace(args.buf, hl_id, 0, -1)
+ local lines = vim.api.nvim_buf_get_lines(args.buf, 0, -1, false)
+
+ -- Map status codes to highlight groups
+ for i, line in ipairs(lines) do
+ local status = line:match('^(%a) ')
+ local hl_group = highlight_groups[status]
+ if hl_group then
+ vim.hl.range(args.buf, hl_id, hl_group, { i - 1, 0 }, { i - 1, 1 })
+ end
+ end
+ end,
+ })
+
+ vim.api.nvim_create_autocmd('BufWinEnter', {
+ group = layout.group,
+ pattern = '*',
+ callback = function()
+ local entry = get_diff_entry()
+ if not entry then
+ return
+ end
+
+ vim.schedule(function()
+ diff_files(entry.user_data.left, entry.user_data.right, true)
+ end)
+ end,
+ })
+
+ left = vim.fs.normalize(left)
+ right = vim.fs.normalize(right)
+
+ if vim.fn.isdirectory(left) == 1 and vim.fn.isdirectory(right) == 1 then
+ diff_dirs(left, right, config)
+ elseif vim.fn.filereadable(left) == 1 and vim.fn.filereadable(right) == 1 then
+ diff_files(left, right)
+ else
+ vim.notify('Both arguments must be files or directories', vim.log.levels.ERROR)
+ end
+end
+
+return M
diff --git a/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua b/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua
new file mode 100644
index 0000000000..cae828995a
--- /dev/null
+++ b/runtime/pack/dist/opt/nvim.difftool/plugin/difftool.lua
@@ -0,0 +1,12 @@
+if vim.g.loaded_difftool ~= nil then
+ return
+end
+vim.g.loaded_difftool = true
+
+vim.api.nvim_create_user_command('DiffTool', function(opts)
+ if #opts.fargs == 2 then
+ require('difftool').open(opts.fargs[1], opts.fargs[2])
+ else
+ vim.notify('Usage: DiffTool ', vim.log.levels.ERROR)
+ end
+end, { nargs = '*', complete = 'file' })
diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua
index cadf5254f7..cf02b8af0b 100755
--- a/src/gen/gen_vimdoc.lua
+++ b/src/gen/gen_vimdoc.lua
@@ -413,6 +413,7 @@ local config = {
plugins = {
filename = 'plugins.txt',
section_order = {
+ 'difftool.lua',
'editorconfig.lua',
'tohtml.lua',
'undotree.lua',
@@ -421,6 +422,7 @@ local config = {
'runtime/lua/editorconfig.lua',
'runtime/lua/tohtml.lua',
'runtime/pack/dist/opt/nvim.undotree/lua/undotree.lua',
+ 'runtime/pack/dist/opt/nvim.difftool/lua/difftool.lua',
},
fn_xform = function(fun)
if fun.module == 'editorconfig' then
diff --git a/test/functional/plugin/difftool_spec.lua b/test/functional/plugin/difftool_spec.lua
new file mode 100644
index 0000000000..e6d67b22be
--- /dev/null
+++ b/test/functional/plugin/difftool_spec.lua
@@ -0,0 +1,64 @@
+local t = require('test.testutil')
+local n = require('test.functional.testnvim')()
+
+local clear = n.clear
+local command = n.command
+local eq = t.eq
+local fn = n.fn
+
+local pathsep = n.get_pathsep()
+local testdir_left = 'Xtest-difftool-left'
+local testdir_right = 'Xtest-difftool-right'
+
+setup(function()
+ n.mkdir_p(testdir_left)
+ n.mkdir_p(testdir_right)
+ t.write_file(testdir_left .. pathsep .. 'file1.txt', 'hello')
+ t.write_file(testdir_left .. pathsep .. 'file2.txt', 'foo')
+ t.write_file(testdir_right .. pathsep .. 'file1.txt', 'hello world') -- modified
+ t.write_file(testdir_right .. pathsep .. 'file3.txt', 'bar') -- added
+end)
+
+teardown(function()
+ n.rmdir(testdir_left)
+ n.rmdir(testdir_right)
+end)
+
+describe('nvim.difftool', function()
+ before_each(function()
+ clear()
+ command('packadd nvim.difftool')
+ end)
+
+ it('shows added, modified, and deleted files in quickfix', function()
+ command(('DiffTool %s %s'):format(testdir_left, testdir_right))
+ local qflist = fn.getqflist()
+ local entries = {}
+ for _, item in ipairs(qflist) do
+ table.insert(entries, { text = item.text, rel = item.user_data and item.user_data.rel })
+ end
+
+ -- Should show:
+ -- file1.txt as modified (M)
+ -- file2.txt as deleted (D)
+ -- file3.txt as added (A)
+ eq({
+ { text = 'M', rel = 'file1.txt' },
+ { text = 'D', rel = 'file2.txt' },
+ { text = 'A', rel = 'file3.txt' },
+ }, entries)
+ end)
+
+ it('has autocmds when diff window is opened', function()
+ command(('DiffTool %s %s'):format(testdir_left, testdir_right))
+ local autocmds = fn.nvim_get_autocmds({ group = 'nvim.difftool.events' })
+ assert(#autocmds > 0)
+ end)
+
+ it('cleans up autocmds when diff window is closed', function()
+ command(('DiffTool %s %s'):format(testdir_left, testdir_right))
+ command('q')
+ local ok = pcall(fn.nvim_get_autocmds, { group = 'nvim.difftool.events' })
+ eq(false, ok)
+ end)
+end)