diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c950aa..3159431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index d3cbb6a..199c86c 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -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) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 3da542d..c6b8b70 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -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()