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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,10 +352,19 @@ Review commits on a per-commit basis:

" Show commits for a specific file
:CodeDiff history HEAD~10 path/to/file.lua

" Show commits in chronological order (oldest first)
:CodeDiff history --reverse
:CodeDiff history HEAD~10 --reverse
:CodeDiff history origin/main..HEAD -r
:CodeDiff history HEAD~20 % --reverse
```

The history panel shows a list of commits. Each commit can be expanded to show its changed files. Select a file to view the diff between the commit and its parent (`commit^` vs `commit`).

**Options:**
- `--reverse` or `-r`: Show commits in chronological order (oldest first) instead of reverse chronological. Useful for following development story from beginning to end, or reviewing PR changes in the order they were made.

**History Keymaps:**
- `i` - Toggle between list and tree view for files under commits

Expand Down
37 changes: 32 additions & 5 deletions lua/codediff/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ end
-- Handle file history command
-- range: git range (e.g., "origin/main..HEAD", "HEAD~10")
-- file_path: optional file path to filter history
local function handle_history(range, file_path)
local function handle_history(range, file_path, flags)
flags = flags or {} -- Default to empty table for backward compat
local current_buf = vim.api.nvim_get_current_buf()
local current_file = vim.api.nvim_buf_get_name(current_buf)
local cwd = vim.fn.getcwd()
Expand All @@ -186,6 +187,11 @@ local function handle_history(range, file_path)
no_merges = true,
}

-- Apply reverse flag if present
if flags.reverse then
history_opts.reverse = true
end

-- Only apply default limit when no range specified
if not range or range == "" then
history_opts.limit = 100
Expand Down Expand Up @@ -627,16 +633,37 @@ function M.vscode_diff(opts)
end
handle_dir_diff(args[2], args[3])
elseif subcommand == "history" then
-- :CodeDiff history [range] [file]
-- :CodeDiff history [range] [file] [--reverse|-r]
-- Examples:
-- :CodeDiff history - last 100 commits
-- :CodeDiff history HEAD~10 - last 10 commits
-- :CodeDiff history origin/main..HEAD - commits in range
-- :CodeDiff history HEAD~10 % - last 10 commits for current file
-- :CodeDiff history % - history for current file
-- :CodeDiff history path/to/file.lua - history for specific file
local arg1 = args[2]
local arg2 = args[3]
-- :CodeDiff history --reverse - last 100 commits (oldest first)
-- :CodeDiff history HEAD~10 -r - last 10 commits (oldest first)

-- Import flag parser
local args_parser = require("codediff.core.args")

-- Define flag spec for history command
local flag_spec = {
["--reverse"] = { short = "-r", type = "boolean" },
}

-- Parse args: separate positional from flags
local remaining_args = vim.list_slice(args, 2) -- Skip "history" subcommand
local positional, flags, parse_err = args_parser.parse_args(remaining_args, flag_spec)

if parse_err then
vim.notify("Error: " .. parse_err, vim.log.levels.ERROR)
return
end

-- Use positional[1], positional[2] instead of args[2], args[3]
local arg1 = positional[1]
local arg2 = positional[2]
local range = nil
local file_path = nil

Expand All @@ -663,7 +690,7 @@ function M.vscode_diff(opts)
end
end

handle_history(range, file_path)
handle_history(range, file_path, flags)
elseif subcommand == "install" or subcommand == "install!" then
-- :CodeDiff install or :CodeDiff install!
-- Handle both :CodeDiff! install and :CodeDiff install!
Expand Down
54 changes: 54 additions & 0 deletions lua/codediff/core/args.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
--- Flag and argument parser for CodeDiff commands
--- Separates positional arguments from flags, supporting both long (--flag) and short (-f) forms
local M = {}

--- Parse command arguments separating positional args from flags
--- @param args table Raw args array (excluding subcommand)
--- @param flag_spec table Flag specifications
--- Example: { ["--reverse"] = { short = "-r", type = "boolean" } }
--- @return table|nil positional Positional arguments array
--- @return table|nil flags Parsed flags table (flag_name = value)
--- @return string|nil error Error message if parsing failed
function M.parse_args(args, flag_spec)
local positional = {}
local flags = {}
local i = 1

while i <= #args do
local arg = args[i]
local is_flag = false

-- Check if arg is a flag (starts with - or --)
if arg:match("^%-") then
for long_name, spec in pairs(flag_spec) do
if arg == long_name or (spec.short and arg == spec.short) then
is_flag = true
local flag_key = long_name:gsub("^%-%-", "")

if spec.type == "boolean" then
flags[flag_key] = true
elseif spec.type == "string" then
i = i + 1
if i > #args then
return nil, nil, "Flag " .. arg .. " requires a value"
end
flags[flag_key] = args[i]
end
break
end
end

if not is_flag then
return nil, nil, "Unknown flag: " .. arg
end
else
table.insert(positional, arg)
end

i = i + 1
end

return positional, flags, nil
end

return M
43 changes: 34 additions & 9 deletions plugin/codediff.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ local function get_cached_rev_candidates(git_root)
end

-- Register user command with subcommand completion
local function complete_codediff(arg_lead, cmd_line, cursor_pos)
local function complete_codediff(arg_lead, cmd_line, _)
local args = vim.split(cmd_line, "%s+", { trimempty = true })

-- If no args or just ":CodeDiff", suggest subcommands and revisions
Expand All @@ -56,7 +56,10 @@ local function complete_codediff(arg_lead, cmd_line, cursor_pos)
local cwd = vim.fn.getcwd()
local git_root = git.get_git_root_sync(cwd)
local rev_candidates = get_cached_rev_candidates(git_root)
return vim.list_extend(candidates, rev_candidates)
if rev_candidates then
vim.list_extend(candidates, rev_candidates)
end
return candidates
end

-- If first arg is "merge" or "file", complete with file paths
Expand All @@ -65,6 +68,24 @@ local function complete_codediff(arg_lead, cmd_line, cursor_pos)
return vim.fn.getcompletion(arg_lead, "file")
end

-- Special handling for history subcommand flags
if first_arg == "history" then
-- If arg_lead starts with -, complete flags
if arg_lead:match("^%-") then
local flag_candidates = { "--reverse", "-r" }
local filtered = {}
for _, flag in ipairs(flag_candidates) do
if flag:find(arg_lead, 1, true) == 1 then
table.insert(filtered, flag)
end
end
if #filtered > 0 then
return filtered
end
end
-- Otherwise fall through to default completion (files, revisions)
end

-- For revision arguments, suggest git refs filtered by arg_lead
if #args == 2 and arg_lead ~= "" then
local cwd = vim.fn.getcwd()
Expand All @@ -76,20 +97,24 @@ local function complete_codediff(arg_lead, cmd_line, cursor_pos)
local base_rev = arg_lead:match("^(.+)%.%.%.$")
if base_rev then
-- User typed "main...", suggest completing with refs or leave as-is
for _, candidate in ipairs(rev_candidates) do
table.insert(filtered, base_rev .. "..." .. candidate)
if rev_candidates then
for _, candidate in ipairs(rev_candidates) do
table.insert(filtered, base_rev .. "..." .. candidate)
end
end
-- Also include the bare triple-dot (compares to working tree)
table.insert(filtered, 1, arg_lead)
return filtered
end

-- Normal completion: match refs and also suggest triple-dot variants
for _, candidate in ipairs(rev_candidates) do
if candidate:find(arg_lead, 1, true) == 1 then
table.insert(filtered, candidate)
-- Also suggest the merge-base variant
table.insert(filtered, candidate .. "...")
if rev_candidates then
for _, candidate in ipairs(rev_candidates) do
if candidate:find(arg_lead, 1, true) == 1 then
table.insert(filtered, candidate)
-- Also suggest the merge-base variant
table.insert(filtered, candidate .. "...")
end
end
end
if #filtered > 0 then
Expand Down
67 changes: 67 additions & 0 deletions tests/history_flags_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
describe("History Flag Parsing", function()
local args_parser = require("codediff.core.args")
local flag_spec = { ["--reverse"] = { short = "-r", type = "boolean" } }

it("parses --reverse with no positional args", function()
local positional, flags = args_parser.parse_args({ "--reverse" }, flag_spec)
assert.are.same({}, positional)
assert.are.same({ reverse = true }, flags)
end)

it("parses --reverse after range", function()
local positional, flags = args_parser.parse_args({ "HEAD~10", "--reverse" }, flag_spec)
assert.are.same({ "HEAD~10" }, positional)
assert.are.same({ reverse = true }, flags)
end)

it("parses -r (short form)", function()
local positional, flags = args_parser.parse_args({ "origin/main..HEAD", "-r" }, flag_spec)
assert.are.same({ "origin/main..HEAD" }, positional)
assert.are.same({ reverse = true }, flags)
end)

it("parses --reverse with range and file", function()
local positional, flags = args_parser.parse_args({ "HEAD~20", "%", "--reverse" }, flag_spec)
assert.are.same({ "HEAD~20", "%" }, positional)
assert.are.same({ reverse = true }, flags)
end)

it("returns error for unknown flag", function()
local positional, flags, err = args_parser.parse_args({ "--invalid" }, flag_spec)
assert.is_nil(positional)
assert.is_nil(flags)
assert.matches("Unknown flag", err)
end)

it("handles no flags gracefully", function()
local positional, flags = args_parser.parse_args({ "HEAD~10", "%" }, flag_spec)
assert.are.same({ "HEAD~10", "%" }, positional)
assert.are.same({}, flags)
end)

it("handles empty args", function()
local positional, flags = args_parser.parse_args({}, flag_spec)
assert.are.same({}, positional)
assert.are.same({}, flags)
end)

it("handles multiple flags", function()
local multi_spec = {
["--reverse"] = { short = "-r", type = "boolean" },
["--limit"] = { short = "-n", type = "string" },
}
local positional, flags = args_parser.parse_args({ "HEAD~10", "--reverse", "--limit", "50" }, multi_spec)
assert.are.same({ "HEAD~10" }, positional)
assert.are.same({ reverse = true, limit = "50" }, flags)
end)

it("returns error for string flag without value", function()
local string_spec = {
["--author"] = { short = "-a", type = "string" },
}
local positional, flags, err = args_parser.parse_args({ "--author" }, string_spec)
assert.is_nil(positional)
assert.is_nil(flags)
assert.matches("requires a value", err)
end)
end)
27 changes: 5 additions & 22 deletions tests/run_plenary_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,11 @@ cd "$PROJECT_ROOT"
# Run all spec files
FAILED=0

# Test files
SPEC_FILES=(
"tests/lazy_spec.lua"
"tests/ffi_integration_spec.lua"
"tests/installer_spec.lua"
"tests/timeout_spec.lua"
"tests/git_integration_spec.lua"
"tests/completion_spec.lua"
"tests/autoscroll_spec.lua"
"tests/explorer_spec.lua"
"tests/explorer_staging_spec.lua"
"tests/explorer_file_filter_spec.lua"
"tests/dir_spec.lua"
"tests/render/semantic_tokens_spec.lua"
"tests/render/core_spec.lua"
"tests/render/lifecycle_spec.lua"
"tests/render/view_spec.lua"
"tests/render/merge_alignment_spec.lua"
"tests/integration_diagnostics_spec.lua"
"tests/keymap_restore_spec.lua"
"tests/full_integration_spec.lua"
)
# Auto-discover all *_spec.lua files (POSIX-compatible)
SPEC_FILES=()
while IFS= read -r file; do
SPEC_FILES+=("$file")
done < <(find tests -name '*_spec.lua' -type f | sort)

for spec_file in "${SPEC_FILES[@]}"; do
echo -e "${CYAN}Running: $spec_file${NC}"
Expand Down