From 603c966c71c71000ac4ddd3ac691b16ef3e7be3c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:53:02 -0500 Subject: [PATCH] fix(fugitive): handle git-quoted filenames in status buffer Problem: git quotes filenames containing spaces, unicode, or special characters (e.g. M "path with spaces/file.lua"). parse_file_line passed the quotes through, causing file-not-found on diff operations. Solution: add unquote() helper that strips surrounding quotes and unescapes \\, \", \n, \t, and octal \NNN sequences. Apply it to both filename returns in parse_file_line. --- lua/diffs/fugitive.lua | 49 ++++++++++++++++++++++++++++++++++++++-- spec/fugitive_spec.lua | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index b11985c..d4c3782 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,17 +26,62 @@ function M.get_section_at_line(bufnr, lnum) return nil end +---@param s string +---@return string +local function unquote(s) + if s:sub(1, 1) ~= '"' then + return s + end + local inner = s:sub(2, -2) + local result = {} + local i = 1 + while i <= #inner do + if inner:sub(i, i) == '\\' and i < #inner then + local next_char = inner:sub(i + 1, i + 1) + if next_char == 'n' then + table.insert(result, '\n') + i = i + 2 + elseif next_char == 't' then + table.insert(result, '\t') + i = i + 2 + elseif next_char == '"' then + table.insert(result, '"') + i = i + 2 + elseif next_char == '\\' then + table.insert(result, '\\') + i = i + 2 + elseif next_char:match('%d') then + local oct = inner:match('^(%d%d%d)', i + 1) + if oct then + table.insert(result, string.char(tonumber(oct, 8))) + i = i + 4 + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, inner:sub(i, i)) + i = i + 1 + end + end + return table.concat(result) +end + ---@param line string ---@return string?, string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return vim.trim(new), vim.trim(old), 'R' + return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' end local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') if status and filename then - return vim.trim(filename), nil, status + return unquote(vim.trim(filename)), nil, status end return nil, nil, nil diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 244e1b7..3ea6c59 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -243,6 +243,57 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('unquotes git-quoted filenames with spaces', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "path with spaces/file.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('path with spaces/file.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes escaped quotes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "file\\"name.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('file"name.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes octal escapes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "\\303\\251le.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('\195\169le.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('passes through unquoted filenames unchanged', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M normal.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('normal.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes renamed files with quotes', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R100 "old name.lua" -> "new name.lua"', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new name.lua', filename) + assert.equals('old name.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + it('handles deeply nested paths', function() local buf = create_status_buffer({ 'Unstaged (1)',