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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- A file whose `.gitattributes` sets a clean filter, `ident` or `working-tree-encoding` showed changes `git diff` doesn't, and staging its hunks could stage different content from `git add`. Its worktree side now reads as git would store it

## [0.2.2] — 2026-10-09

### Fixed

- `X` on a staged rename overwrote a file recreated at the old path with its `HEAD` content. It now undoes the rename and leaves that file as it is

## [0.2.1] — 2026-10-09
Expand Down
31 changes: 27 additions & 4 deletions lua/differ/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -182,21 +182,44 @@ local function read_file(abs)
return data
end

-- whether an attribute that rewrites LF-only content on `git add` is set for `relpath`
---@param root string
---@param relpath string
---@return boolean
local function has_clean_attr(root, relpath)
local attrs = { "filter", "ident", "working-tree-encoding" }
local out =
git(vim.list_extend({ "check-attr" }, vim.list_extend(attrs, { "--", relpath })), root)
if not out then
return false
end
for line in out:gmatch("[^\n]+") do
local value = line:match(": ([^:]*)$")
if value ~= "unspecified" and value ~= "unset" then
return true
end
end
return false
end

-- worktree bytes as `git add` would store them (the clean filter: eol
-- conversion, text attrs, custom filters), so worktree-side diffs compare in
-- the repo domain, like `git diff`, and the hunk patches built from the model
-- stage the same blob a `git add` of the file would write. runs the real
-- `git add` against a throwaway copy of the index and reads the entry back, so
-- every conversion rule (safe-crlf stickiness included) is git's own, not a
-- reimplementation. gated on a CR byte in non-binary content: LF-only content
-- can't convert differently, so the common case pays nothing. any failure
-- falls back to the raw bytes (the old behaviour)
-- reimplementation. gated on non-binary content that has a CR byte or a
-- converting attribute (has_clean_attr): nothing else converts, so the common
-- case pays one check-attr. any failure falls back to the raw bytes
---@param root string
---@param relpath string
---@param data string
---@return string
local function as_staged(root, relpath, data)
if not data:find("\r", 1, true) or text_util.is_binary(data) then
if text_util.is_binary(data) then
return data
end
if not data:find("\r", 1, true) and not has_clean_attr(root, relpath) then
return data
end
local index = git({ "rev-parse", "--git-path", "index" }, root)
Expand Down
47 changes: 47 additions & 0 deletions test/nvim/git_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,53 @@ describe("git.read (worktree clean filter)", function()
-- the staged blob is byte-identical to what `git add` would have written
assert.are.equal("one\nNEW LINE\ntwo\n", raw_indexed(root, "c.txt"))
end)

-- a repo whose `filter` driver drops `secret` lines from conf.txt on the way in
local function filtered_repo()
local root = fresh_repo()
git(root, "config", "filter.strip.clean", "sed /^secret/d")
git(root, "config", "filter.strip.smudge", "cat")
write(root .. "/.gitattributes", "conf.txt filter=strip\n")
write(root .. "/conf.txt", "a\nb\n")
git(root, "add", ".gitattributes", "conf.txt")
git(root, "commit", "-q", "-m", "filtered file")
return root
end

it("runs a custom clean filter over LF-only worktree content", function()
local root = filtered_repo()
write(root .. "/conf.txt", "a\nsecret 1\nb\n")
assert.are.equal("a\nb\n", git_src.read(wt, root, "conf.txt"))

local source = { old = { kind = "index", label = "INDEX" }, new = wt }
local model = git_src.model(source, root, { path = "conf.txt" })
assert.are.equal(0, #model.hunks)
end)

it("hunk staging leaves filtered-out lines out of the index", function()
local root = filtered_repo()
write(root .. "/conf.txt", "a\nsecret 1\nNEW\nb\n")

local source = { old = { kind = "index", label = "INDEX" }, new = wt }
local model = git_src.model(source, root, { path = "conf.txt" })
assert.are.equal(1, #model.hunks)
local text = require("differ.model.apply").splice(model, { true })
assert.is_true(require("differ.git.index").write_index(root, "conf.txt", text))
assert.are.equal("a\nNEW\nb\n", raw_indexed(root, "conf.txt"))
end)

it("collapses an expanded ident keyword the way git add would", function()
local root = fresh_repo()
write(root .. "/.gitattributes", "id.txt ident\n")
write(root .. "/id.txt", "$Id: 0123abcd $\n")
assert.are.equal("$Id$\n", git_src.read(wt, root, "id.txt"))
end)

it("reads a file with no filter attribute raw", function()
local root = filtered_repo()
write(root .. "/a.lua", "secret 1\n")
assert.are.equal("secret 1\n", git_src.read(wt, root, "a.lua"))
end)
end)

describe("git.revert_patch", function()
Expand Down
Loading