Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions lua/codediff/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ local function handle_file_diff(file_a, file_b, global_opts)
end
end

local function handle_dir_diff(dir1, dir2, global_opts)
-- opts.paths: optional list of relative paths to restrict the comparison to.
-- When provided, only those paths are diffed (missing on either side => a
-- creation/deletion), avoiding a full scan of the (possibly huge) trees.
local function handle_dir_diff(dir1, dir2, global_opts, opts)
opts = opts or {}
local dir_mod = require("codediff.core.dir")

-- Expand ~ and environment variables in paths
Expand All @@ -175,9 +179,16 @@ local function handle_dir_diff(dir1, dir2, global_opts)
return
end

local diff = dir_mod.diff_directories(dir1, dir2)
local diff = dir_mod.diff_directories(dir1, dir2, { paths = opts.paths })
local status_result = diff.status_result

if diff.missing and #diff.missing > 0 then
vim.notify(
("codediff: %d path(s) not found in either directory (ignored), e.g. %s"):format(#diff.missing, diff.missing[1]),
vim.log.levels.WARN
)
end

if #status_result.unstaged == 0 and #status_result.staged == 0 then
vim.notify("No differences between directories", vim.log.levels.INFO)
return
Expand All @@ -194,12 +205,22 @@ local function handle_dir_diff(dir1, dir2, global_opts)
layout = global_opts.layout,
explorer_data = {
status_result = status_result,
paths = opts.paths, -- carried so refresh keeps the same filter
},
}

view.create(session_config, "")
end

-- Public entry point for a path-restricted directory diff.
-- opts.paths: list of relative paths to compare (required for the filtered
-- behavior; omit for a full-tree diff, equivalent to `:CodeDiff dir1 dir2`).
-- opts.layout: "inline" | "side-by-side" (optional).
function M.dir_diff(dir1, dir2, opts)
opts = opts or {}
handle_dir_diff(dir1, dir2, { layout = opts.layout }, { paths = opts.paths })
end

-- Handle file history command
-- range: git range (e.g., "origin/main..HEAD", "HEAD~10")
-- file_path: optional file path to filter history
Expand Down
109 changes: 86 additions & 23 deletions lua/codediff/core/dir.lua
Original file line number Diff line number Diff line change
Expand Up @@ -99,48 +99,110 @@ local function is_modified(a, b)
return false
end

-- Stat a single file. Returns a meta table ({ path, abs, size }) for regular
-- files, or nil for anything that is missing or is not a regular file (e.g. a
-- directory or symlink to a directory).
local function stat_file(abs, rel)
local stat = uv.fs_stat(abs)
if not stat or stat.type ~= "file" then
return nil
end
return {
path = rel,
abs = abs,
size = stat.size,
}
end

-- Build a status_result restricted to an explicit list of relative paths.
-- Instead of scanning the trees, each path is stat'd in both roots and
-- classified (A / D / M). This is O(#paths) rather than O(tree), which is the
-- point of the filter: it avoids touching the (potentially huge) rest of the
-- trees. Paths absent from both roots are collected into `missing`.
local function diff_paths(root1, root2, paths)
local unstaged = {}
local missing = {}
local seen = {}

for _, rel in ipairs(paths) do
-- Normalize separators and strip any leading "./" or "/" so lookups are
-- consistent regardless of how the caller spelled the path.
rel = rel:gsub("\\", "/"):gsub("^%./", ""):gsub("^/+", "")
if rel ~= "" and not seen[rel] then
seen[rel] = true

local meta1 = stat_file(root1 .. "/" .. rel, rel)
local meta2 = stat_file(root2 .. "/" .. rel, rel)

if meta1 and meta2 then
if is_modified(meta1, meta2) then
table.insert(unstaged, { path = rel, status = "M" })
end
elseif meta1 then
table.insert(unstaged, { path = rel, status = "D" })
elseif meta2 then
table.insert(unstaged, { path = rel, status = "A" })
else
table.insert(missing, rel)
end
end
end

return unstaged, missing
end

-- Compare two directories and return a git-like status_result.
-- dir1 = "original", dir2 = "modified"
-- NOTE: Modification detection compares file content with readblob.
function M.diff_directories(dir1, dir2)
-- opts.paths: optional list of relative paths. When provided, only those paths
-- are compared (each stat'd directly rather than scanning the trees). A path
-- missing on one side is a creation/deletion against the other; a path
-- missing on both sides is reported in the returned `missing` list.
function M.diff_directories(dir1, dir2, opts)
opts = opts or {}
local root1 = normalize_dir(dir1)
local root2 = normalize_dir(dir2)

local files1 = scan_dir(root1)
local files2 = scan_dir(root2)

local result = {
unstaged = {},
staged = {},
conflicts = {}, -- Empty for dir mode, but consistent with git status shape
}
local missing = nil

local seen = {}
if opts.paths and #opts.paths > 0 then
result.unstaged, missing = diff_paths(root1, root2, opts.paths)
else
local files1 = scan_dir(root1)
local files2 = scan_dir(root2)

local seen = {}

for path, meta1 in pairs(files1) do
local meta2 = files2[path]
if not meta2 then
table.insert(result.unstaged, {
path = path,
status = "D",
})
else
seen[path] = true
if is_modified(meta1, meta2) then
for path, meta1 in pairs(files1) do
local meta2 = files2[path]
if not meta2 then
table.insert(result.unstaged, {
path = path,
status = "M",
status = "D",
})
else
seen[path] = true
if is_modified(meta1, meta2) then
table.insert(result.unstaged, {
path = path,
status = "M",
})
end
end
end
end

for path, _ in pairs(files2) do
if not seen[path] then
table.insert(result.unstaged, {
path = path,
status = "A",
})
for path, _ in pairs(files2) do
if not seen[path] then
table.insert(result.unstaged, {
path = path,
status = "A",
})
end
end
end

Expand All @@ -152,6 +214,7 @@ function M.diff_directories(dir1, dir2)
status_result = result,
root1 = root1,
root2 = root2,
missing = missing,
}
end

Expand Down
13 changes: 13 additions & 0 deletions lua/codediff/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ function M.setup(opts)
render.setup_highlights()
end

-- Open a directory diff, optionally restricted to a list of relative paths.
-- dir1 = "original", dir2 = "modified".
-- opts.paths: list of relative paths to compare. When provided, only those
-- paths are diffed (each stat'd directly rather than scanning the trees);
-- a path missing on one side shows as a creation/deletion, and paths missing
-- on both sides are reported via a warning and ignored. Omit for a full diff.
-- opts.layout: "inline" | "side-by-side" (optional).
-- Example:
-- require("codediff").dir_diff("/a", "/b", { paths = { "src/x.lua", "README" } })
function M.dir_diff(dir1, dir2, opts)
return require("codediff.commands").dir_diff(dir1, dir2, opts)
end

-- Navigate to next hunk in the current diff view
-- Returns true if navigation succeeded, false otherwise
function M.next_hunk()
Expand Down
2 changes: 1 addition & 1 deletion lua/codediff/ui/explorer/refresh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ function M.refresh(explorer)
if not explorer.git_root then
-- Dir mode: re-scan directories
local dir_mod = require("codediff.core.dir")
local diff = dir_mod.diff_directories(explorer.dir1, explorer.dir2)
local diff = dir_mod.diff_directories(explorer.dir1, explorer.dir2, { paths = explorer.paths })
process_result(nil, diff.status_result)
elseif explorer.base_revision and explorer.target_revision and explorer.target_revision ~= "WORKING" then
git.get_diff_revisions(explorer.base_revision, explorer.target_revision, explorer.git_root, process_result)
Expand Down
2 changes: 2 additions & 0 deletions lua/codediff/ui/explorer/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ function M.create(status_result, git_root, tabpage, width, base_revision, target
tabpage = tabpage,
dir1 = opts.dir1,
dir2 = opts.dir2,
paths = opts.paths, -- Dir mode: restrict refresh to these relative paths

base_revision = base_revision,
target_revision = target_revision,
status_result = status_result, -- Store initial status result
Expand Down
1 change: 1 addition & 0 deletions lua/codediff/ui/view/panel.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function M.setup_explorer(tabpage, session_config, original_win, modified_win)
if not session_config.git_root then
explorer_opts.dir1 = session_config.original_path
explorer_opts.dir2 = session_config.modified_path
explorer_opts.paths = session_config.explorer_data.paths
end
if session_config.explorer_data.focus_file then
explorer_opts.focus_file = session_config.explorer_data.focus_file
Expand Down
80 changes: 80 additions & 0 deletions tests/core/dir_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,84 @@ describe('dir module', function()
vim.fn.delete(dir2, 'rf')
end)
end)

describe('diff_directories with opts.paths filter', function()
it('only reports listed paths and ignores others', function()
local dir1 = helpers.create_temp_dir()
local dir2 = helpers.create_temp_dir()

-- Listed path, modified
vim.fn.writefile({ 'old' }, dir1 .. '/keep.txt')
vim.fn.writefile({ 'new' }, dir2 .. '/keep.txt')
-- Unlisted path that differs — must be ignored
vim.fn.writefile({ 'x' }, dir1 .. '/ignore.txt')
vim.fn.writefile({ 'y' }, dir2 .. '/ignore.txt')

local result = dir_mod.diff_directories(dir1, dir2, { paths = { 'keep.txt' } })

assert.equals(1, #result.status_result.unstaged)
assert.equals('keep.txt', result.status_result.unstaged[1].path)
assert.equals('M', result.status_result.unstaged[1].status)

vim.fn.delete(dir1, 'rf')
vim.fn.delete(dir2, 'rf')
end)

it('classifies a listed path missing on one side as A or D', function()
local dir1 = helpers.create_temp_dir()
local dir2 = helpers.create_temp_dir()

vim.fn.writefile({ 'content' }, dir1 .. '/only1.txt')
vim.fn.writefile({ 'content' }, dir2 .. '/only2.txt')

local result = dir_mod.diff_directories(dir1, dir2, { paths = { 'only1.txt', 'only2.txt' } })

local by_path = {}
for _, f in ipairs(result.status_result.unstaged) do
by_path[f.path] = f.status
end
assert.equals('D', by_path['only1.txt'])
assert.equals('A', by_path['only2.txt'])

vim.fn.delete(dir1, 'rf')
vim.fn.delete(dir2, 'rf')
end)

it('reports paths missing on both sides via missing', function()
local dir1 = helpers.create_temp_dir()
local dir2 = helpers.create_temp_dir()

vim.fn.writefile({ 'a' }, dir1 .. '/present.txt')
vim.fn.writefile({ 'a' }, dir2 .. '/present.txt')

local result = dir_mod.diff_directories(dir1, dir2, { paths = { 'present.txt', 'ghost.txt' } })

-- present.txt is identical => no diff entry
assert.equals(0, #result.status_result.unstaged)
assert.is_not_nil(result.missing)
assert.equals(1, #result.missing)
assert.equals('ghost.txt', result.missing[1])

vim.fn.delete(dir1, 'rf')
vim.fn.delete(dir2, 'rf')
end)

it('normalizes leading ./ and duplicate paths', function()
local dir1 = helpers.create_temp_dir()
local dir2 = helpers.create_temp_dir()

vim.fn.mkdir(dir1 .. '/sub', 'p')
vim.fn.mkdir(dir2 .. '/sub', 'p')
vim.fn.writefile({ 'old' }, dir1 .. '/sub/f.txt')
vim.fn.writefile({ 'new' }, dir2 .. '/sub/f.txt')

local result = dir_mod.diff_directories(dir1, dir2, { paths = { './sub/f.txt', 'sub/f.txt' } })

assert.equals(1, #result.status_result.unstaged)
assert.equals('sub/f.txt', result.status_result.unstaged[1].path)

vim.fn.delete(dir1, 'rf')
vim.fn.delete(dir2, 'rf')
end)
end)
end)