diff --git a/README.md b/README.md index 14aa1965..8f7f5708 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lua/codediff/commands.lua b/lua/codediff/commands.lua index c3fb028b..3ef6cdca 100644 --- a/lua/codediff/commands.lua +++ b/lua/codediff/commands.lua @@ -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() @@ -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 @@ -627,7 +633,7 @@ 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 @@ -635,8 +641,29 @@ function M.vscode_diff(opts) -- :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 @@ -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! diff --git a/lua/codediff/core/args.lua b/lua/codediff/core/args.lua new file mode 100644 index 00000000..3207f483 --- /dev/null +++ b/lua/codediff/core/args.lua @@ -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 diff --git a/plugin/codediff.lua b/plugin/codediff.lua index 507826d4..d4bad304 100644 --- a/plugin/codediff.lua +++ b/plugin/codediff.lua @@ -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 @@ -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 @@ -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() @@ -76,8 +97,10 @@ 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) @@ -85,11 +108,13 @@ local function complete_codediff(arg_lead, cmd_line, cursor_pos) 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 diff --git a/tests/history_flags_spec.lua b/tests/history_flags_spec.lua new file mode 100644 index 00000000..36877b87 --- /dev/null +++ b/tests/history_flags_spec.lua @@ -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) diff --git a/tests/run_plenary_tests.sh b/tests/run_plenary_tests.sh index 74866972..eea2193f 100755 --- a/tests/run_plenary_tests.sh +++ b/tests/run_plenary_tests.sh @@ -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}"