Skip to content
Merged
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
14 changes: 14 additions & 0 deletions lua/gitlad/ui/views/diff/content.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 32 additions & 5 deletions lua/gitlad/ui/views/diff/hunk.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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/(.+)$")
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions lua/gitlad/ui/views/diff/source.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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)
Expand Down
153 changes: 153 additions & 0 deletions tests/e2e/test_diff_view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-- =============================================================================
Expand Down
76 changes: 69 additions & 7 deletions tests/unit/test_diff_content.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
{
Expand Down Expand Up @@ -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()
Expand Down
Loading