diff --git a/lua/codediff/commands.lua b/lua/codediff/commands.lua index f3c56343..0bc8cf66 100644 --- a/lua/codediff/commands.lua +++ b/lua/codediff/commands.lua @@ -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 @@ -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 @@ -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 diff --git a/lua/codediff/core/dir.lua b/lua/codediff/core/dir.lua index 890c2fb3..21b05262 100644 --- a/lua/codediff/core/dir.lua +++ b/lua/codediff/core/dir.lua @@ -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 @@ -152,6 +214,7 @@ function M.diff_directories(dir1, dir2) status_result = result, root1 = root1, root2 = root2, + missing = missing, } end diff --git a/lua/codediff/init.lua b/lua/codediff/init.lua index 431a4850..1929bfb6 100644 --- a/lua/codediff/init.lua +++ b/lua/codediff/init.lua @@ -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() diff --git a/lua/codediff/ui/explorer/refresh.lua b/lua/codediff/ui/explorer/refresh.lua index c08ea549..df8adb5d 100644 --- a/lua/codediff/ui/explorer/refresh.lua +++ b/lua/codediff/ui/explorer/refresh.lua @@ -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) diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index 78605a05..82f56fd3 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -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 diff --git a/lua/codediff/ui/view/panel.lua b/lua/codediff/ui/view/panel.lua index cb872d2a..459d890f 100644 --- a/lua/codediff/ui/view/panel.lua +++ b/lua/codediff/ui/view/panel.lua @@ -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 diff --git a/tests/core/dir_spec.lua b/tests/core/dir_spec.lua index 36de525c..14c6bb65 100644 --- a/tests/core/dir_spec.lua +++ b/tests/core/dir_spec.lua @@ -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)