diff --git a/lua/codediff/commands.lua b/lua/codediff/commands.lua index f3c56343..4577b649 100644 --- a/lua/codediff/commands.lua +++ b/lua/codediff/commands.lua @@ -221,9 +221,7 @@ local function handle_history(range, file_path, flags, line_range, global_opts) local function open_history(git_root) -- Build options for commit list - local history_opts = { - no_merges = true, - } + local history_opts = {} -- Apply reverse flag if present if flags.reverse then @@ -275,6 +273,7 @@ local function handle_history(range, file_path, flags, line_range, global_opts) range = range, file_path = history_opts.path, base_revision = flags.base, + reverse = history_opts.reverse, line_range = line_range, }, } diff --git a/lua/codediff/core/git.lua b/lua/codediff/core/git.lua index d260c574..cc56f462 100644 --- a/lua/codediff/core/git.lua +++ b/lua/codediff/core/git.lua @@ -782,7 +782,7 @@ function M.get_commit_list(range, git_root, opts, callback) local args = { "log", - "--pretty=format:%H%x00%h%x00%an%x00%at%x00%ar%x00%s%x00%D%x00", + "--pretty=format:%H%x00%h%x00%an%x00%at%x00%ar%x00%s%x00%D%x00%P", } if is_line_range then @@ -790,10 +790,17 @@ function M.get_commit_list(range, git_root, opts, callback) local l_arg = string.format("-L%d,%d:%s", opts.line_range[1], opts.line_range[2], opts.path) table.insert(args, l_arg) elseif is_single_file then + -- For single file mode, keep full history and request first-parent merge diffs + -- so merge commits that touched the file remain visible without duplicating + -- one entry per parent. + table.insert(args, "--full-history") + table.insert(args, "--diff-merges=first-parent") -- For single file mode, use --numstat to get stats AND file path (for renames) table.insert(args, "--numstat") table.insert(args, "--follow") else + -- For commit history mode, show merge commit stats against first parent. + table.insert(args, "--diff-merges=first-parent") -- For multi-file mode, use --shortstat for aggregate stats table.insert(args, "--shortstat") end @@ -839,7 +846,8 @@ function M.get_commit_list(range, git_root, opts, callback) end local parts = vim.split(line, "\0") - if #parts >= 7 then + if #parts >= 8 then + local parent_hashes = parts[8] ~= "" and vim.split(parts[8], " ", { trimempty = true }) or {} current_commit = { hash = parts[1], short_hash = parts[2], @@ -848,6 +856,9 @@ function M.get_commit_list(range, git_root, opts, callback) date_relative = parts[5], subject = parts[6], ref_names = parts[7] ~= "" and parts[7] or nil, + parent_hashes = parent_hashes, + parent_count = #parent_hashes, + parent_revision = parent_hashes[1], files_changed = 0, insertions = 0, deletions = 0, @@ -914,8 +925,36 @@ end -- git_root: absolute path to git repository root -- callback: function(err, files) where files is array of: -- { path, status, old_path } -function M.get_commit_files(commit_hash, git_root, callback) - run_git_async({ "diff-tree", "--no-commit-id", "--name-status", "-r", "-M", commit_hash }, { cwd = git_root }, function(err, output) +function M.get_commit_files(commit_hash, git_root, opts, callback) + if type(opts) == "function" then + callback = opts + opts = {} + end + + opts = opts or {} + + local args + if opts.parent_revision and opts.parent_revision ~= "" then + args = { + "diff", + "--name-status", + "-M", + opts.parent_revision, + commit_hash, + } + else + args = { + "diff-tree", + "--root", + "--no-commit-id", + "--name-status", + "-r", + "-M", + commit_hash, + } + end + + run_git_async(args, { cwd = git_root }, function(err, output) if err then callback(err, nil) return diff --git a/lua/codediff/ui/history/nodes.lua b/lua/codediff/ui/history/nodes.lua index 69fdf042..e05a0d69 100644 --- a/lua/codediff/ui/history/nodes.lua +++ b/lua/codediff/ui/history/nodes.lua @@ -75,8 +75,11 @@ end -- files: array of { path, status, old_path } -- commit_hash: the commit hash these files belong to -- git_root: absolute path to git repository root -function M.create_list_file_nodes(files, commit_hash, git_root) +function M.create_list_file_nodes(files, commit_data, git_root) local file_nodes = {} + local commit_hash = commit_data.hash + local parent_revision = commit_data.parent_revision + local parent_count = commit_data.parent_count for i, file in ipairs(files) do local icon, icon_color = M.get_file_icon(file.path) @@ -96,6 +99,8 @@ function M.create_list_file_nodes(files, commit_hash, git_root) status_color = status_info.color, git_root = git_root, commit_hash = commit_hash, + parent_revision = parent_revision, + parent_count = parent_count, is_last = i == #files, indent_state = { i == #files }, }, @@ -109,9 +114,12 @@ end -- files: array of { path, status, old_path } -- commit_hash: the commit hash these files belong to -- git_root: absolute path to git repository root -function M.create_tree_file_nodes(files, commit_hash, git_root) +function M.create_tree_file_nodes(files, commit_data, git_root) -- Build directory structure local dir_tree = {} + local commit_hash = commit_data.hash + local parent_revision = commit_data.parent_revision + local parent_count = commit_data.parent_count for _, file in ipairs(files) do local parts = {} @@ -202,6 +210,8 @@ function M.create_tree_file_nodes(files, commit_hash, git_root) status_color = status_info.color, git_root = git_root, commit_hash = commit_hash, + parent_revision = parent_revision, + parent_count = parent_count, indent_state = node_indent_state, }, }) diff --git a/lua/codediff/ui/history/refresh.lua b/lua/codediff/ui/history/refresh.lua index b0991c45..6bcb75d6 100644 --- a/lua/codediff/ui/history/refresh.lua +++ b/lua/codediff/ui/history/refresh.lua @@ -125,9 +125,14 @@ function M.refresh(history) -- Reconstruct git log options local git_opts = { - no_merges = true, path = history.opts.file_path, } + if history.opts.reverse then + git_opts.reverse = true + end + if history.opts.line_range then + git_opts.line_range = history.opts.line_range + end if not history.opts.range or history.opts.range == "" then git_opts.limit = 100 end diff --git a/lua/codediff/ui/history/render.lua b/lua/codediff/ui/history/render.lua index cf10b442..aeb37ac6 100644 --- a/lua/codediff/ui/history/render.lua +++ b/lua/codediff/ui/history/render.lua @@ -71,6 +71,9 @@ function M.build_tree_nodes(commits, git_root, opts) date_relative = commit.date_relative, subject = commit.subject, ref_names = commit.ref_names, + parent_hashes = commit.parent_hashes, + parent_count = commit.parent_count, + parent_revision = commit.parent_revision, files_changed = commit.files_changed, insertions = commit.insertions, deletions = commit.deletions, @@ -205,7 +208,7 @@ function M.create(commits, git_root, tabpage, width, opts) return end - git.get_commit_files(data.hash, git_root, function(err, files) + git.get_commit_files(data.hash, git_root, { parent_revision = data.parent_revision }, function(err, files) if err then vim.schedule(function() vim.notify("Failed to load commit files: " .. err, vim.log.levels.ERROR) @@ -227,9 +230,9 @@ function M.create(commits, git_root, tabpage, width, opts) local file_nodes if view_mode == "tree" then - file_nodes = nodes_module.create_tree_file_nodes(files, data.hash, git_root) + file_nodes = nodes_module.create_tree_file_nodes(files, data, git_root) else - file_nodes = nodes_module.create_list_file_nodes(files, data.hash, git_root) + file_nodes = nodes_module.create_list_file_nodes(files, data, git_root) end -- Update node with children @@ -275,6 +278,7 @@ function M.create(commits, git_root, tabpage, width, opts) local file_path = file_data.path local old_path = file_data.old_path local commit_hash = file_data.commit_hash + local parent_revision = file_data.parent_revision if not file_path or file_path == "" then vim.notify("[CodeDiff] No file path for selection", vim.log.levels.WARN) @@ -287,7 +291,7 @@ function M.create(commits, git_root, tabpage, width, opts) end -- Check if already displaying same file - local target_hash = base_revision or (commit_hash .. "^") + local target_hash = base_revision or parent_revision or (commit_hash .. "^") local session = lifecycle.get_session(tabpage) if not opts.force and session and session.original_revision == target_hash and session.modified_revision == commit_hash then if session.modified_path == file_path or session.original_path == file_path then @@ -369,6 +373,7 @@ function M.create(commits, git_root, tabpage, width, opts) local file_data = { path = file_path, commit_hash = first_commit_node.data.hash, + parent_revision = first_commit_node.data.parent_revision, git_root = git_root, } history.on_file_select(file_data) diff --git a/tests/core/history_merge_spec.lua b/tests/core/history_merge_spec.lua new file mode 100644 index 00000000..c4b6324c --- /dev/null +++ b/tests/core/history_merge_spec.lua @@ -0,0 +1,111 @@ +local git = require("codediff.core.git") +local helpers = require("tests.helpers") + +describe("History merge commit support", function() + local repo = nil + + before_each(function() + repo = helpers.create_temp_git_repo() + + repo.write_file("target.txt", { "base" }) + repo.git("add target.txt") + repo.git("commit -m 'base commit'") + + repo.git("checkout -b feature") + repo.write_file("target.txt", { "feature change" }) + repo.git("add target.txt") + repo.git("commit -m 'feature change'") + + repo.git("checkout main") + repo.write_file("main.txt", { "main branch change" }) + repo.git("add main.txt") + repo.git("commit -m 'main change'") + + repo.git("merge --no-ff feature -m 'merge feature'") + end) + + after_each(function() + if repo then + repo.cleanup() + repo = nil + end + end) + + it("includes merge commits in single-file history", function() + local done = false + local err_result = nil + local commits_result = nil + + git.get_commit_list("", repo.dir, { path = "target.txt" }, function(err, commits) + err_result = err + commits_result = commits + done = true + end) + + helpers.wait_async(5000, function() + return done + end) + + assert.is_nil(err_result) + assert.is_table(commits_result) + assert.is_true(#commits_result >= 2) + + local merge_commit = nil + for _, commit in ipairs(commits_result) do + if commit.subject == "merge feature" then + merge_commit = commit + break + end + end + + assert.is_not_nil(merge_commit, "Expected file history to include merge commit") + assert.equal(2, merge_commit.parent_count) + assert.is_not_nil(merge_commit.parent_revision) + assert.equal("target.txt", merge_commit.file_path) + end) + + it("lists merge commit files against first parent", function() + local head_output = repo.git("rev-parse HEAD") + local merge_hash = vim.trim(head_output) + + local list_done = false + local commits_result = nil + git.get_commit_list("", repo.dir, { path = "target.txt" }, function(_, commits) + commits_result = commits + list_done = true + end) + + helpers.wait_async(5000, function() + return list_done + end) + + local merge_commit = nil + for _, commit in ipairs(commits_result or {}) do + if commit.hash == merge_hash then + merge_commit = commit + break + end + end + + assert.is_not_nil(merge_commit, "Expected HEAD merge commit in history list") + + local done = false + local err_result = nil + local files_result = nil + git.get_commit_files(merge_commit.hash, repo.dir, { parent_revision = merge_commit.parent_revision }, function(err, files) + err_result = err + files_result = files + done = true + end) + + helpers.wait_async(5000, function() + return done + end) + + assert.is_nil(err_result) + assert.is_table(files_result) + assert.is_true(#files_result >= 1) + assert.equal("target.txt", files_result[1].path) + assert.equal("M", files_result[1].status) + end) +end)