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
5 changes: 2 additions & 3 deletions lua/codediff/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}
Expand Down
47 changes: 43 additions & 4 deletions lua/codediff/core/git.lua
Original file line number Diff line number Diff line change
Expand Up @@ -782,18 +782,25 @@ 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
-- git log -L requires -p or -s format; --numstat/--shortstat/--follow are incompatible
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
Expand Down Expand Up @@ -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],
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions lua/codediff/ui/history/nodes.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 },
},
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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,
},
})
Expand Down
7 changes: 6 additions & 1 deletion lua/codediff/ui/history/refresh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions lua/codediff/ui/history/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
111 changes: 111 additions & 0 deletions tests/core/history_merge_spec.lua
Original file line number Diff line number Diff line change
@@ -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)