diff --git a/lua/gitlad/ui/views/diff/content.lua b/lua/gitlad/ui/views/diff/content.lua index c442961..b8aa8b3 100644 --- a/lua/gitlad/ui/views/diff/content.lua +++ b/lua/gitlad/ui/views/diff/content.lua @@ -93,6 +93,20 @@ function M.align_sides(file_pair) end end + -- Recompute is_hunk_boundary: mark the first non-context line of each change region. + -- With full-context diffs (-U999999) there's only one giant hunk, so the original + -- per-pair is_hunk_boundary (pair_idx == 1) fires only on line 1. This post-processing + -- pass marks context→change transitions so ]c/[c hunk navigation works correctly. + local prev_context = true + local prev_hunk_index = nil + for _, info in ipairs(line_map) do + local is_context = (info.left_type == "context") and (info.right_type == "context") + local new_hunk = (info.hunk_index ~= prev_hunk_index) + info.is_hunk_boundary = (not is_context) and (prev_context or new_hunk) + prev_context = is_context + prev_hunk_index = info.hunk_index + end + return { left_lines = left_lines, right_lines = right_lines, diff --git a/lua/gitlad/ui/views/diff/hunk.lua b/lua/gitlad/ui/views/diff/hunk.lua index 32ba963..d18cc1d 100644 --- a/lua/gitlad/ui/views/diff/hunk.lua +++ b/lua/gitlad/ui/views/diff/hunk.lua @@ -214,8 +214,38 @@ function M.parse_unified_diff(lines) end for _, line in ipairs(lines) do - -- Detect file boundary: "diff --git a/... b/..." - if line:match("^diff %-%-git ") then + if current_hunk_lines then + -- Inside a hunk: check for boundaries that end the hunk, otherwise accumulate. + -- This MUST be checked first — hunk lines like "--- comment" (a deleted Lua + -- comment) or "+++ value" (an added line) would otherwise match the file header + -- patterns below and get silently dropped. + if line:match("^diff %-%-git ") then + finish_file() + + local old_path, new_path = line:match("^diff %-%-git a/(.+) b/(.+)$") + if old_path and new_path then + current_file = { + old_path = old_path, + new_path = new_path, + status = M.detect_file_status(old_path, new_path), + hunks = {}, + additions = 0, + deletions = 0, + is_binary = false, + } + end + elseif line:match("^@@") then + finish_hunk() + local header = M.parse_hunk_header(line) + if header then + current_header = header + current_hunk_lines = {} + end + else + table.insert(current_hunk_lines, line) + end + elseif line:match("^diff %-%-git ") then + -- Detect file boundary: "diff --git a/... b/..." finish_file() local old_path, new_path = line:match("^diff %-%-git a/(.+) b/(.+)$") @@ -252,9 +282,6 @@ function M.parse_unified_diff(lines) current_header = header current_hunk_lines = {} end - elseif current_hunk_lines then - -- Line within a hunk - table.insert(current_hunk_lines, line) elseif line:match("^rename from ") then -- Rename detection if current_file then diff --git a/lua/gitlad/ui/views/diff/source.lua b/lua/gitlad/ui/views/diff/source.lua index 9d499cb..0f82f29 100644 --- a/lua/gitlad/ui/views/diff/source.lua +++ b/lua/gitlad/ui/views/diff/source.lua @@ -16,20 +16,20 @@ local hunk = require("gitlad.ui.views.diff.hunk") ---@return string[] args Git command arguments (without 'git' prefix) function M._build_args(source_type, ref_or_range) if source_type == "staged" then - return { "diff", "--cached" } + return { "diff", "--cached", "-U999999" } elseif source_type == "unstaged" then - return { "diff" } + return { "diff", "-U999999" } elseif source_type == "worktree" then - return { "diff", "HEAD" } + return { "diff", "HEAD", "-U999999" } elseif source_type == "commit" then assert(ref_or_range, "commit source requires a ref") - return { "show", "--format=", ref_or_range } + return { "show", "--format=", "-U999999", ref_or_range } elseif source_type == "range" then assert(ref_or_range, "range source requires a range expression") - return { "diff", ref_or_range } + return { "diff", "-U999999", ref_or_range } elseif source_type == "stash" then assert(ref_or_range, "stash source requires a stash ref") - return { "stash", "show", "-p", ref_or_range } + return { "stash", "show", "-p", "-U999999", ref_or_range } else error("Unknown diff source type: " .. tostring(source_type)) end @@ -200,7 +200,7 @@ end function M._build_pr_args(pr_info, selected_index) if selected_index == nil then -- Full PR diff (three-dot: changes introduced by head relative to merge base) - return { "diff", pr_info.base_oid .. "..." .. pr_info.head_oid }, nil + return { "diff", "-U999999", pr_info.base_oid .. "..." .. pr_info.head_oid }, nil end local commit = pr_info.commits and pr_info.commits[selected_index] @@ -215,7 +215,7 @@ function M._build_pr_args(pr_info, selected_index) else parent = pr_info.commits[selected_index - 1].oid end - return { "diff", parent .. ".." .. commit.oid }, nil + return { "diff", "-U999999", parent .. ".." .. commit.oid }, nil end --- Produce a DiffSpec for a PR (full diff or single commit within the PR) diff --git a/tests/e2e/test_diff_view.lua b/tests/e2e/test_diff_view.lua index 5c57296..0b784b5 100644 --- a/tests/e2e/test_diff_view.lua +++ b/tests/e2e/test_diff_view.lua @@ -869,6 +869,159 @@ T["diff view"]["stash diff opens and shows stashed changes"] = function() helpers.cleanup_repo(child, repo) end +-- ============================================================================= +-- Full file content tests (-U999999) +-- ============================================================================= + +T["diff view"]["commit diff shows full file content not just hunk context"] = function() + -- Regression: without -U999999, only 3 lines of context were shown per hunk, + -- so changes far apart in a long file would only show fragments. + local repo = helpers.create_test_repo(child) + -- Create a 20-line file + local lines = {} + for i = 1, 20 do + lines[i] = "line" .. i + end + helpers.create_file(child, repo, "big.lua", table.concat(lines, "\n") .. "\n") + helpers.git(child, repo, "add big.lua") + helpers.git(child, repo, "commit -m 'initial'") + -- Change line 2 and line 19 (far apart, would be separate hunks with default context) + lines[2] = "CHANGED2" + lines[19] = "CHANGED19" + helpers.create_file(child, repo, "big.lua", table.concat(lines, "\n") .. "\n") + helpers.git(child, repo, "add big.lua") + helpers.git(child, repo, "commit -m 'change lines 2 and 19'") + + helpers.cd(child, repo) + open_commit_diff(repo, "HEAD") + helpers.wait_short(child, 200) + + -- Both buffers should contain the full file (all 20 lines, not just context around hunks) + local result = child.lua_get([=[(function() + local view = require("gitlad.ui.views.diff").get_active() + if not view or not view.buffer_pair then return { left = 0, right = 0, has_line10 = false } end + local left = view.buffer_pair.left_bufnr + local right = view.buffer_pair.right_bufnr + if not vim.api.nvim_buf_is_valid(left) or not vim.api.nvim_buf_is_valid(right) then + return { left = 0, right = 0, has_line10 = false } + end + local left_lines = vim.api.nvim_buf_get_lines(left, 0, -1, false) + local right_lines = vim.api.nvim_buf_get_lines(right, 0, -1, false) + -- Check that a middle line (line10) exists — it would be missing without -U999999 + local has_line10 = false + for _, line in ipairs(right_lines) do + if line:find("line10", 1, true) then has_line10 = true; break end + end + return { left = #left_lines, right = #right_lines, has_line10 = has_line10 } + end)()]=]) + -- Should have all 20 lines (same count since only modifications, no adds/deletes) + eq(result.left >= 20, true) + eq(result.right >= 20, true) + eq(result.has_line10, true) + + helpers.cleanup_repo(child, repo) +end + +-- ============================================================================= +-- Deleted comment line tests (--- prefix parsing) +-- ============================================================================= + +T["diff view"]["deleted Lua comment lines appear in commit diff"] = function() + -- Regression: deleted lines starting with "-- " (Lua comments) produced diff lines + -- like "--- comment" that matched the file header pattern and got silently dropped. + local repo = helpers.create_test_repo(child) + local content = table.concat({ + "local M = {}", + "-- This is a comment", + "-- Another comment", + "function M.hello() end", + "return M", + }, "\n") .. "\n" + helpers.create_file(child, repo, "init.lua", content) + helpers.git(child, repo, "add init.lua") + helpers.git(child, repo, "commit -m 'initial with comments'") + -- Remove the comment lines + local new_content = table.concat({ + "local M = {}", + "function M.hello() end", + "return M", + }, "\n") .. "\n" + helpers.create_file(child, repo, "init.lua", new_content) + helpers.git(child, repo, "add init.lua") + helpers.git(child, repo, "commit -m 'remove comments'") + + helpers.cd(child, repo) + open_commit_diff(repo, "HEAD") + helpers.wait_short(child, 200) + + -- Left buffer (old) should contain the deleted comment lines + local result = child.lua_get([=[(function() + local view = require("gitlad.ui.views.diff").get_active() + if not view or not view.buffer_pair then return { has_comment1 = false, has_comment2 = false } end + local left = view.buffer_pair.left_bufnr + if not vim.api.nvim_buf_is_valid(left) then return { has_comment1 = false, has_comment2 = false } end + local lines = vim.api.nvim_buf_get_lines(left, 0, -1, false) + local has_comment1 = false + local has_comment2 = false + for _, line in ipairs(lines) do + if line:find("This is a comment", 1, true) then has_comment1 = true end + if line:find("Another comment", 1, true) then has_comment2 = true end + end + return { has_comment1 = has_comment1, has_comment2 = has_comment2 } + end)()]=]) + eq(result.has_comment1, true) + eq(result.has_comment2, true) + + helpers.cleanup_repo(child, repo) +end + +T["diff view"]["deleted Lua comment lines appear in staged diff"] = function() + -- Same regression but for staged diffs + local repo = helpers.create_test_repo(child) + local content = table.concat({ + "local M = {}", + "-- Helper function", + "-- Does something useful", + "function M.run() end", + "return M", + }, "\n") .. "\n" + helpers.create_file(child, repo, "mod.lua", content) + helpers.git(child, repo, "add mod.lua") + helpers.git(child, repo, "commit -m 'initial'") + -- Remove the comment lines and stage + local new_content = table.concat({ + "local M = {}", + "function M.run() end", + "return M", + }, "\n") .. "\n" + helpers.create_file(child, repo, "mod.lua", new_content) + helpers.git(child, repo, "add mod.lua") + + helpers.cd(child, repo) + open_staged_diff(repo) + helpers.wait_short(child, 200) + + -- Left buffer (old/index before staging) should contain the deleted comment lines + local result = child.lua_get([=[(function() + local view = require("gitlad.ui.views.diff").get_active() + if not view or not view.buffer_pair then return { has_helper = false, has_useful = false } end + local left = view.buffer_pair.left_bufnr + if not vim.api.nvim_buf_is_valid(left) then return { has_helper = false, has_useful = false } end + local lines = vim.api.nvim_buf_get_lines(left, 0, -1, false) + local has_helper = false + local has_useful = false + for _, line in ipairs(lines) do + if line:find("Helper function", 1, true) then has_helper = true end + if line:find("something useful", 1, true) then has_useful = true end + end + return { has_helper = has_helper, has_useful = has_useful } + end)()]=]) + eq(result.has_helper, true) + eq(result.has_useful, true) + + helpers.cleanup_repo(child, repo) +end + -- ============================================================================= -- Hunk navigation tests (cursor movement) -- ============================================================================= diff --git a/tests/unit/test_diff_content.lua b/tests/unit/test_diff_content.lua index a38fd19..a3c5f83 100644 --- a/tests/unit/test_diff_content.lua +++ b/tests/unit/test_diff_content.lua @@ -391,7 +391,7 @@ T["align_sides"]["hunk_index is set correctly"] = function() eq(result.line_map[3].hunk_index, 3) end -T["align_sides"]["is_hunk_boundary marks first line of each hunk"] = function() +T["align_sides"]["is_hunk_boundary marks context-to-change transitions"] = function() local fp = make_file_pair({ make_hunk(1, 2, 1, 2, { { @@ -435,14 +435,76 @@ T["align_sides"]["is_hunk_boundary marks first line of each hunk"] = function() eq(#result.line_map, 4) - -- First line of first hunk + -- Context line — not a boundary + eq(result.line_map[1].is_hunk_boundary, false) + -- First change after context — boundary + eq(result.line_map[2].is_hunk_boundary, true) + -- Context line in second hunk — not a boundary + eq(result.line_map[3].is_hunk_boundary, false) + -- First change after context — boundary + eq(result.line_map[4].is_hunk_boundary, true) +end + +T["align_sides"]["is_hunk_boundary marks multiple change regions in single hunk"] = function() + -- Simulates -U999999 merging two change regions into one hunk + local fp = make_file_pair({ + make_hunk(1, 5, 1, 5, { + { + left_line = "a", + right_line = "A", + left_type = "change", + right_type = "change", + left_lineno = 1, + right_lineno = 1, + }, + { + left_line = "b", + right_line = "b", + left_type = "context", + right_type = "context", + left_lineno = 2, + right_lineno = 2, + }, + { + left_line = "c", + right_line = "c", + left_type = "context", + right_type = "context", + left_lineno = 3, + right_lineno = 3, + }, + { + left_line = "d", + right_line = "D", + left_type = "change", + right_type = "change", + left_lineno = 4, + right_lineno = 4, + }, + { + left_line = "e", + right_line = "E", + left_type = "change", + right_type = "change", + left_lineno = 5, + right_lineno = 5, + }, + }), + }) + + local result = content.align_sides(fp) + + eq(#result.line_map, 5) + + -- First non-context line (start of first change region) eq(result.line_map[1].is_hunk_boundary, true) - -- Second line of first hunk + -- Context lines eq(result.line_map[2].is_hunk_boundary, false) - -- First line of second hunk - eq(result.line_map[3].is_hunk_boundary, true) - -- Second line of second hunk - eq(result.line_map[4].is_hunk_boundary, false) + eq(result.line_map[3].is_hunk_boundary, false) + -- First change after context (start of second change region) + eq(result.line_map[4].is_hunk_boundary, true) + -- Continuation of change region + eq(result.line_map[5].is_hunk_boundary, false) end T["align_sides"]["empty file pair produces empty result"] = function() diff --git a/tests/unit/test_diff_hunk.lua b/tests/unit/test_diff_hunk.lua index 905be75..9f1ca88 100644 --- a/tests/unit/test_diff_hunk.lua +++ b/tests/unit/test_diff_hunk.lua @@ -481,4 +481,63 @@ T["parse_unified_diff"]["handles file with only context (mode change)"] = functi eq(#fps[1].hunks, 0) end +T["parse_unified_diff"]["deleted Lua comment lines (--- prefix) are not swallowed"] = function() + -- Regression: deleted lines starting with "-- " produce diff lines like "--- comment" + -- which matched the file header pattern "^--- " and got silently dropped. + local lines = { + "diff --git a/init.lua b/init.lua", + "--- a/init.lua", + "+++ b/init.lua", + "@@ -1,5 +1,3 @@", + " local M = {}", + "--- This is a comment", + "--- Another comment", + " return M", + } + local fps = hunk.parse_unified_diff(lines) + eq(#fps, 1) + eq(fps[1].status, "M") + eq(#fps[1].hunks, 1) + + local pairs = fps[1].hunks[1].pairs + -- Should have 4 lines: context, 2 deletions, context + eq(#pairs, 4) + eq(pairs[1].left_type, "context") + eq(pairs[1].left_line, "local M = {}") + eq(pairs[2].left_type, "delete") + eq(pairs[2].left_line, "-- This is a comment") + eq(pairs[2].right_type, "filler") + eq(pairs[3].left_type, "delete") + eq(pairs[3].left_line, "-- Another comment") + eq(pairs[3].right_type, "filler") + eq(pairs[4].left_type, "context") + eq(pairs[4].left_line, "return M") +end + +T["parse_unified_diff"]["added lines with +++ prefix are not swallowed"] = function() + -- Same issue: added lines starting with "++" produce "+++ ..." matching the header pattern. + local lines = { + "diff --git a/file.lua b/file.lua", + "--- a/file.lua", + "+++ b/file.lua", + "@@ -1,2 +1,4 @@", + " local M = {}", + "+++ This is a weird line", + "+++ Another one", + " return M", + } + local fps = hunk.parse_unified_diff(lines) + eq(#fps, 1) + eq(#fps[1].hunks, 1) + + local pairs = fps[1].hunks[1].pairs + eq(#pairs, 4) + eq(pairs[1].right_type, "context") + eq(pairs[2].right_type, "add") + eq(pairs[2].right_line, "++ This is a weird line") + eq(pairs[3].right_type, "add") + eq(pairs[3].right_line, "++ Another one") + eq(pairs[4].right_type, "context") +end + return T diff --git a/tests/unit/test_diff_source.lua b/tests/unit/test_diff_source.lua index 150f2a2..d85d470 100644 --- a/tests/unit/test_diff_source.lua +++ b/tests/unit/test_diff_source.lua @@ -14,47 +14,47 @@ T["_build_args"] = MiniTest.new_set() T["_build_args"]["builds staged diff args"] = function() local args = source._build_args("staged") - eq(args, { "diff", "--cached" }) + eq(args, { "diff", "--cached", "-U999999" }) end T["_build_args"]["builds unstaged diff args"] = function() local args = source._build_args("unstaged") - eq(args, { "diff" }) + eq(args, { "diff", "-U999999" }) end T["_build_args"]["builds worktree diff args"] = function() local args = source._build_args("worktree") - eq(args, { "diff", "HEAD" }) + eq(args, { "diff", "HEAD", "-U999999" }) end T["_build_args"]["builds commit diff args with ref"] = function() local args = source._build_args("commit", "abc1234") - eq(args, { "show", "--format=", "abc1234" }) + eq(args, { "show", "--format=", "-U999999", "abc1234" }) end T["_build_args"]["builds commit diff args with full hash"] = function() local args = source._build_args("commit", "abc1234def5678abc1234def5678abc1234def567") - eq(args, { "show", "--format=", "abc1234def5678abc1234def5678abc1234def567" }) + eq(args, { "show", "--format=", "-U999999", "abc1234def5678abc1234def5678abc1234def567" }) end T["_build_args"]["builds range diff args"] = function() local args = source._build_args("range", "main..HEAD") - eq(args, { "diff", "main..HEAD" }) + eq(args, { "diff", "-U999999", "main..HEAD" }) end T["_build_args"]["builds range diff args with three-dot range"] = function() local args = source._build_args("range", "main...feature") - eq(args, { "diff", "main...feature" }) + eq(args, { "diff", "-U999999", "main...feature" }) end T["_build_args"]["builds stash diff args"] = function() local args = source._build_args("stash", "stash@{0}") - eq(args, { "stash", "show", "-p", "stash@{0}" }) + eq(args, { "stash", "show", "-p", "-U999999", "stash@{0}" }) end T["_build_args"]["builds stash diff args with numbered ref"] = function() local args = source._build_args("stash", "stash@{3}") - eq(args, { "stash", "show", "-p", "stash@{3}" }) + eq(args, { "stash", "show", "-p", "-U999999", "stash@{3}" }) end T["_build_args"]["errors on commit without ref"] = function() @@ -278,6 +278,7 @@ T["integration"]["staged source produces correct args and spec"] = function() local args = source._build_args("staged") eq(args[1], "diff") eq(args[2], "--cached") + eq(args[3], "-U999999") local s = { type = "staged" } local spec = source._build_diff_spec(s, { {}, {} }, "/repo") @@ -289,7 +290,8 @@ T["integration"]["commit source produces correct args and spec"] = function() local args = source._build_args("commit", ref) eq(args[1], "show") eq(args[2], "--format=") - eq(args[3], ref) + eq(args[3], "-U999999") + eq(args[4], ref) local s = { type = "commit", ref = ref } local spec = source._build_diff_spec(s, { {} }, "/repo") @@ -303,7 +305,8 @@ T["integration"]["stash source produces correct args and spec"] = function() eq(args[1], "stash") eq(args[2], "show") eq(args[3], "-p") - eq(args[4], stash_ref) + eq(args[4], "-U999999") + eq(args[5], stash_ref) local s = { type = "stash", ref = stash_ref } local spec = source._build_diff_spec(s, {}, "/repo") @@ -361,28 +364,28 @@ T["_build_pr_args"]["builds three-dot diff for full PR diff (selected_index nil) local pr_info = make_pr_info() local args, err = source._build_pr_args(pr_info, nil) eq(err, nil) - eq(args, { "diff", pr_info.base_oid .. "..." .. pr_info.head_oid }) + eq(args, { "diff", "-U999999", pr_info.base_oid .. "..." .. pr_info.head_oid }) end T["_build_pr_args"]["uses base_oid as parent for first commit"] = function() local pr_info = make_pr_info() local args, err = source._build_pr_args(pr_info, 1) eq(err, nil) - eq(args, { "diff", pr_info.base_oid .. ".." .. pr_info.commits[1].oid }) + eq(args, { "diff", "-U999999", pr_info.base_oid .. ".." .. pr_info.commits[1].oid }) end T["_build_pr_args"]["uses previous commit as parent for subsequent commits"] = function() local pr_info = make_pr_info() local args, err = source._build_pr_args(pr_info, 2) eq(err, nil) - eq(args, { "diff", pr_info.commits[1].oid .. ".." .. pr_info.commits[2].oid }) + eq(args, { "diff", "-U999999", pr_info.commits[1].oid .. ".." .. pr_info.commits[2].oid }) end T["_build_pr_args"]["uses previous commit as parent for third commit"] = function() local pr_info = make_pr_info() local args, err = source._build_pr_args(pr_info, 3) eq(err, nil) - eq(args, { "diff", pr_info.commits[2].oid .. ".." .. pr_info.commits[3].oid }) + eq(args, { "diff", "-U999999", pr_info.commits[2].oid .. ".." .. pr_info.commits[3].oid }) end T["_build_pr_args"]["returns error for invalid commit index"] = function() @@ -466,7 +469,8 @@ T["integration"]["PR full diff builds correct args and title"] = function() local args, err = source._build_pr_args(pr_info, nil) eq(err, nil) eq(args[1], "diff") - expect.equality(args[2]:match("%.%.%.") ~= nil, true) -- three-dot diff + eq(args[2], "-U999999") + expect.equality(args[3]:match("%.%.%.") ~= nil, true) -- three-dot diff local s = { type = "pr", pr_info = pr_info } local spec = source._build_diff_spec(s, { {}, {} }, "/repo") @@ -478,7 +482,8 @@ T["integration"]["PR single commit builds correct args and title"] = function() local args, err = source._build_pr_args(pr_info, 2) eq(err, nil) eq(args[1], "diff") - expect.equality(args[2]:match("%.%.") ~= nil, true) -- two-dot diff + eq(args[2], "-U999999") + expect.equality(args[3]:match("%.%.") ~= nil, true) -- two-dot diff local s = { type = "pr", pr_info = pr_info, selected_commit = 2 } local spec = source._build_diff_spec(s, { {} }, "/repo")