fix(git/cmd): parse :G args with shell-style quoting

This commit is contained in:
2026-05-07 13:49:52 +02:00
parent 6d1fb8f7d3
commit b25a35dd8e
5 changed files with 200 additions and 28 deletions
+1 -1
View File
@@ -248,7 +248,7 @@ vim.keymap.set("n", "<leader>gc", function()
require("git.commit").commit() require("git.commit").commit()
end) end)
vim.keymap.set("n", "<leader>ga", function() vim.keymap.set("n", "<leader>ga", function()
require("git.commit").commit({ amend = true }) require("git.commit").commit({ args = { "--amend" } })
end) end)
vim.keymap.set("n", "<leader>gl", function() vim.keymap.set("n", "<leader>gl", function()
require('git.log_view').open({ max_count = 1000 }) require('git.log_view').open({ max_count = 1000 })
+95 -22
View File
@@ -42,6 +42,95 @@ local function git_cmds()
return cached_cmds return cached_cmds
end 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 args string[]
---@param start integer ---@param start integer
---@return string? ---@return string?
@@ -139,18 +228,6 @@ local function run_to_messages(r, args)
) )
end 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[] ---@param args string[]
---@return boolean ---@return boolean
local function has_message(args) local function has_message(args)
@@ -177,14 +254,13 @@ function M.run(args)
local sub = args[1] local sub = args[1]
if sub == "commit" and not has_message(args) then 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 return
end end
if sub == "show" then if sub == "show" then
local arg = first_positional(args, 2) if #args == 2 and args[2]:find(":", 1, true) then
if arg and arg:find(":", 1, true) then object.open(r, args[2])
object.open(r, arg)
return return
end end
run_in_split(r, args, { ft = "git", needs_rev = true }) run_in_split(r, args, { ft = "git", needs_rev = true })
@@ -192,12 +268,9 @@ function M.run(args)
end end
if sub == "cat-file" then if sub == "cat-file" then
if vim.list_contains(args, "-p") then if #args == 3 and args[2] == "-p" then
local rev = first_positional(args, 2) object.open(r, args[3])
if rev then return
object.open(r, rev)
return
end
end end
run_in_split(r, args, { ft = "git", needs_rev = true }) run_in_split(r, args, { ft = "git", needs_rev = true })
return return
+3 -4
View File
@@ -4,9 +4,8 @@ local util = require("git.util")
local M = {} local M = {}
---@param opts { amend: boolean? }? ---@param opts { args: string[]? }?
function M.commit(opts) function M.commit(opts)
local amend = opts and opts.amend or false
local r = repo.resolve() local r = repo.resolve()
if not r then if not r then
util.error("not in a git repository") util.error("not in a git repository")
@@ -14,8 +13,8 @@ function M.commit(opts)
end end
local cmd = { "git", "commit" } local cmd = { "git", "commit" }
if amend then if opts and opts.args then
table.insert(cmd, "--amend") vim.list_extend(cmd, opts.args)
end end
local proxy_buf, proxy_win local proxy_buf, proxy_win
+2 -1
View File
@@ -137,7 +137,8 @@ function M.init()
}) })
vim.api.nvim_create_user_command("G", function(opts) 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, { end, {
nargs = "*", nargs = "*",
complete = function(...) complete = function(...)
+99
View File
@@ -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)