diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index 5fc6684..39681c5 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -248,7 +248,7 @@ vim.keymap.set("n", "gc", function() require("git.commit").commit() end) vim.keymap.set("n", "ga", function() - require("git.commit").commit({ amend = true }) + require("git.commit").commit({ args = { "--amend" } }) end) vim.keymap.set("n", "gl", function() require('git.log_view').open({ max_count = 1000 }) diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index 5ca540b..0bab41d 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -42,6 +42,95 @@ local function git_cmds() return cached_cmds end +---@param tok string +---@return boolean +local function is_expansion_target(tok) + local first = tok:sub(1, 1) + if first == "%" or first == "#" then + return true + end + if tok:match("^<%w+>") then + return true + end + if tok == "~" or tok:sub(1, 2) == "~/" then + return true + end + return false +end + +---@param line string +---@param i integer +---@param buf string[] +---@param escapes string? +---@return integer +local function parse_quoted(line, i, buf, escapes) + local quote = line:sub(i, i) + local n = #line + i = i + 1 + while i <= n do + local c = line:sub(i, i) + if c == quote then + return i + 1 + elseif escapes and c == "\\" and i < n then + local nxt = line:sub(i + 1, i + 1) + if escapes:find(nxt, 1, true) then + table.insert(buf, nxt) + i = i + 2 + else + table.insert(buf, c) + i = i + 1 + end + else + table.insert(buf, c) + i = i + 1 + end + end + return i +end + +---@param line string +---@return string[] +function M.parse_args(line) + local args = {} + local i, n = 1, #line + while i <= n do + local c = line:sub(i, i) + if c == " " or c == "\t" then + i = i + 1 + else + local buf = {} + local quoted = false + while i <= n do + c = line:sub(i, i) + if c == " " or c == "\t" then + break + elseif c == "\\" and i < n then + table.insert(buf, line:sub(i + 1, i + 1)) + i = i + 2 + elseif c == '"' then + quoted = true + i = parse_quoted(line, i, buf, '"\\$`') + elseif c == "'" then + quoted = true + i = parse_quoted(line, i, buf, nil) + else + table.insert(buf, c) + i = i + 1 + end + end + local tok = table.concat(buf) + if not quoted and is_expansion_target(tok) then + local expanded = vim.fn.expand(tok) --[[@as string]] + if expanded ~= "" then + tok = expanded + end + end + table.insert(args, tok) + end + end + return args +end + ---@param args string[] ---@param start integer ---@return string? @@ -139,18 +228,6 @@ local function run_to_messages(r, args) ) end ----@param args string[] ----@param flag string ----@return boolean -local function has_flag(args, flag) - for _, a in ipairs(args) do - if a == flag then - return true - end - end - return false -end - ---@param args string[] ---@return boolean local function has_message(args) @@ -177,14 +254,13 @@ function M.run(args) local sub = args[1] if sub == "commit" and not has_message(args) then - commit.commit({ amend = has_flag(args, "--amend") }) + commit.commit({ args = vim.list_slice(args, 2) }) return end if sub == "show" then - local arg = first_positional(args, 2) - if arg and arg:find(":", 1, true) then - object.open(r, arg) + if #args == 2 and args[2]:find(":", 1, true) then + object.open(r, args[2]) return end run_in_split(r, args, { ft = "git", needs_rev = true }) @@ -192,12 +268,9 @@ function M.run(args) end if sub == "cat-file" then - if vim.list_contains(args, "-p") then - local rev = first_positional(args, 2) - if rev then - object.open(r, rev) - return - end + if #args == 3 and args[2] == "-p" then + object.open(r, args[3]) + return end run_in_split(r, args, { ft = "git", needs_rev = true }) return diff --git a/lua/git/commit.lua b/lua/git/commit.lua index 407e80c..2b29cfd 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -4,9 +4,8 @@ local util = require("git.util") local M = {} ----@param opts { amend: boolean? }? +---@param opts { args: string[]? }? function M.commit(opts) - local amend = opts and opts.amend or false local r = repo.resolve() if not r then util.error("not in a git repository") @@ -14,8 +13,8 @@ function M.commit(opts) end local cmd = { "git", "commit" } - if amend then - table.insert(cmd, "--amend") + if opts and opts.args then + vim.list_extend(cmd, opts.args) end local proxy_buf, proxy_win diff --git a/lua/git/init.lua b/lua/git/init.lua index eed4b28..509705e 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -137,7 +137,8 @@ function M.init() }) vim.api.nvim_create_user_command("G", function(opts) - require("git.cmd").run(opts.fargs) + local cmd = require("git.cmd") + cmd.run(cmd.parse_args(opts.args)) end, { nargs = "*", complete = function(...) diff --git a/test/git/cmd_test.lua b/test/git/cmd_test.lua new file mode 100644 index 0000000..069389e --- /dev/null +++ b/test/git/cmd_test.lua @@ -0,0 +1,99 @@ +local t = require("test") +local cmd = require("git.cmd") + +t.test("parse_args splits on whitespace", function() + t.eq( + cmd.parse_args("config user.name value"), + { "config", "user.name", "value" } + ) +end) + +t.test("parse_args preserves spaces inside double quotes", function() + t.eq( + cmd.parse_args([[config user.name "Oscar Wallberg"]]), + { "config", "user.name", "Oscar Wallberg" } + ) +end) + +t.test("parse_args preserves spaces inside single quotes", function() + t.eq( + cmd.parse_args([[log --grep='bug fix' --author=Oscar]]), + { "log", "--grep=bug fix", "--author=Oscar" } + ) +end) + +t.test("parse_args handles backslash-escaped space", function() + t.eq(cmd.parse_args([[a\ b c]]), { "a b", "c" }) +end) + +t.test("parse_args handles escaped quote inside double quotes", function() + t.eq(cmd.parse_args([["a\"b" c]]), { 'a"b', "c" }) +end) + +t.test( + "parse_args treats backslash literally inside single quotes", + function() + t.eq(cmd.parse_args([['a\b' c]]), { "a\\b", "c" }) + end +) + +t.test("parse_args concatenates adjacent quoted segments", function() + t.eq(cmd.parse_args([[foo"bar"baz]]), { "foobarbaz" }) +end) + +t.test("parse_args handles tabs as separators", function() + t.eq(cmd.parse_args("a\tb\tc"), { "a", "b", "c" }) +end) + +t.test("parse_args returns empty list for empty or whitespace input", function() + t.eq(cmd.parse_args(""), {}) + t.eq(cmd.parse_args(" \t "), {}) +end) + +t.test("parse_args preserves empty quoted token", function() + t.eq(cmd.parse_args([[a "" b]]), { "a", "", "b" }) +end) + +t.test("parse_args expands %% on unquoted token", function() + local buf = vim.api.nvim_create_buf(false, false) + t.defer(function() + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") + vim.api.nvim_set_current_buf(buf) + + t.eq( + cmd.parse_args("add %"), + { "add", vim.fn.getcwd() .. "/some-file.lua" } + ) +end) + +t.test("parse_args does not expand %% inside double quotes", function() + local buf = vim.api.nvim_create_buf(false, false) + t.defer(function() + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") + vim.api.nvim_set_current_buf(buf) + + t.eq(cmd.parse_args([[log -- "%"]]), { "log", "--", "%" }) +end) + +t.test("parse_args does not expand %% inside single quotes", function() + local buf = vim.api.nvim_create_buf(false, false) + t.defer(function() + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + vim.api.nvim_buf_set_name(buf, vim.fn.getcwd() .. "/some-file.lua") + vim.api.nvim_set_current_buf(buf) + + t.eq(cmd.parse_args([[log -- '%']]), { "log", "--", "%" }) +end) + +t.test("parse_args does not treat mid-token tilde as expansion", function() + t.eq(cmd.parse_args("checkout HEAD~3"), { "checkout", "HEAD~3" }) +end) + +t.test("parse_args expands leading ~/ to home", function() + t.eq(cmd.parse_args("add ~/foo"), { "add", vim.fn.expand("~/foo") }) +end)