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
5 changes: 5 additions & 0 deletions .claude/review-lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,8 @@
- **問題**: 「同一性ガードはコールバック先頭に置く」というルールの先例として`init.reload`を挙げたが、実際には各取得コールバックの更新条件内と完了バリア後で確認する構造で、ルールの参照先と説明が食い違っていた。`captured_state`をgrepしてヒットしたファイルを、構造を読まずに先例として列挙していた
- **対策**: ルールの根拠として既存コードを名指しするときは、そのコードがルールの要件(位置・順序・条件)を本当に満たしているかを該当関数を読んで確認する。grepで「同じ識別子を使っている」ことと「同じ規則に従っている」ことは別で、満たさない例を挙げるとルール自体の信頼性を下げる
- **該当箇所**: .claude/review-lessons.md

### ドキュメント: 入力元が生成しない分岐を根拠に機能を文書化した (PR #233, 2026-10-10)
- **問題**: `parse_name_status`の`C`(copy)分岐があることを根拠に「copied fileも旧パスと比較する」と書いたが、実際の入力元`diff.get_name_status`は`git diff -M`でrenameしか検出しないため、local modeではcopyが`added`になり分岐に到達しなかった
- **対策**: パーサーの分岐を根拠に挙動を文書化するときは、呼び出し元のコマンドやAPIがその形式を実際に出力するか(gitのフラグ、APIのstatus値)まで遡って確認する。到達しない分岐はmode限定で書くか、記載から外す
- **該当箇所**: lua/fude/local/session.lua, doc/fude.txt
8 changes: 4 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Not sure which command you need? `:FudeCommandPalette` opens a command palette l
| `:FudeReviewStart` | Start review session (PR detection, comments, extmarks) |
| `:FudeReviewStop` | Stop review session |
| `:FudeReviewToggle` | Toggle review session |
| `:FudeReviewDiff` | Toggle diff preview window |
| `:FudeReviewDiff` | Toggle diff preview window (renamed files diff against their old path) |
| `:FudeReviewComment` | Create pending comment on current line/selection |
| `:FudeReviewSuggest` | Create pending suggestion on current line/selection |
| `:FudeReviewViewComment` | View comments on current line (`r` reply, `e` edit, `d` delete, `R` resolve/unresolve the thread) |
Expand Down
2 changes: 2 additions & 0 deletions doc/fude.txt
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ Using lazy.nvim: >lua
Toggle the diff preview window. Opens a side-by-side diff view
showing the base branch version of the current file, or closes
it if already open. Requires an active review session.
For a renamed file, the base version is read from its old path,
so the diff shows the content changes rather than a new file.

:FudeReviewComment *:FudeReviewComment*
Create a review comment on the current line (normal mode) or
Expand Down
22 changes: 22 additions & 0 deletions lua/fude/gh.lua
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,28 @@ function M.get_pr_files(pr_number, callback)
}, callback)
end

--- Convert a files listing from the GitHub API (`pulls/{pr}/files` or
--- `commits/{sha}`.files) to the changed_files entries used across the plugin.
--- `previous_path` is set only for renamed/copied files, whose base content
--- lives under the old name.
--- @param files table[] API file objects
--- @return table[] changed files { path, previous_path?, status, additions, deletions, patch }
function M.build_changed_files(files)
local changed = {}
for _, f in ipairs(files) do
table.insert(changed, {
path = f.filename,
-- JSON null decodes to vim.NIL, so check the type rather than truthiness
previous_path = type(f.previous_filename) == "string" and f.previous_filename or nil,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
return changed
end

--- Get review comments on a PR.
--- @param pr_number number
--- @param callback fun(err: string|nil, comments: table|nil)
Expand Down
44 changes: 4 additions & 40 deletions lua/fude/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -239,32 +239,14 @@ function M.start()
if started_detached and state.original_head_sha then
gh_mod.get_commit_files(state.original_head_sha, function(files_err, files)
if not files_err and files then
state.changed_files = {}
for _, f in ipairs(files) do
table.insert(state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
state.changed_files = gh_mod.build_changed_files(files)
end
on_ready()
end)
else
gh_mod.get_pr_files(state.pr_number, function(files_err, files)
if not files_err and files then
state.changed_files = {}
for _, f in ipairs(files) do
table.insert(state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
state.changed_files = gh_mod.build_changed_files(files)
end
on_ready()
end)
Expand Down Expand Up @@ -674,32 +656,14 @@ function M.reload(silent)
if config.state.scope == "commit" and config.state.scope_commit_sha then
gh_mod.get_commit_files(config.state.scope_commit_sha, function(err, files)
if not err and files and config.state == captured_state and config.state.active then
config.state.changed_files = {}
for _, f in ipairs(files) do
table.insert(config.state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
config.state.changed_files = gh_mod.build_changed_files(files)
end
on_done()
end)
else
gh_mod.get_pr_files(config.state.pr_number, function(err, files)
if not err and files and config.state == captured_state and config.state.active then
config.state.changed_files = {}
for _, f in ipairs(files) do
table.insert(config.state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
config.state.changed_files = gh_mod.build_changed_files(files)
end
on_done()
end)
Expand Down
16 changes: 9 additions & 7 deletions lua/fude/local/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ end

--- Parse `git diff --name-status -M` output.
--- @param output string|nil
--- @return table[] entries { path, status }
--- @return table[] entries { path, previous_path?, status }
function M.parse_name_status(output)
local entries = {}
if not output or output == "" then
Expand All @@ -51,15 +51,16 @@ function M.parse_name_status(output)
for line in output:gmatch("[^\n]+") do
local letter, rest = line:match("^(%S+)\t(.+)$")
if letter and rest then
local path = rest
local path, previous_path = rest, nil
if letter:sub(1, 1) == "R" or letter:sub(1, 1) == "C" then
Comment thread
Copilot marked this conversation as resolved.
-- "R100\told\tnew" — the review target is the new path
local _, new = rest:match("^(.-)\t(.+)$")
-- "R100\told\tnew" — the review target is the new path; the old
-- one is kept because the base content lives under it
local old, new = rest:match("^(.-)\t(.+)$")
if new then
path = new
path, previous_path = new, old
end
end
table.insert(entries, { path = path, status = M.status_word(letter) })
table.insert(entries, { path = path, previous_path = previous_path, status = M.status_word(letter) })
end
end
return entries
Expand Down Expand Up @@ -101,7 +102,7 @@ end
--- @param name_status_out string|nil `git diff --name-status -M` output
--- @param numstat_out string|nil `git diff --numstat -M` output
--- @param untracked_out string|nil `git ls-files --others --exclude-standard` output
--- @return table[] changed files { path, status, additions, deletions }
--- @return table[] changed files { path, previous_path?, status, additions, deletions }
function M.build_changed_files(name_status_out, numstat_out, untracked_out)
local counts = M.parse_numstat(numstat_out)
local files = {}
Expand All @@ -111,6 +112,7 @@ function M.build_changed_files(name_status_out, numstat_out, untracked_out)
local c = counts[entry.path] or {}
table.insert(files, {
path = entry.path,
previous_path = entry.previous_path,
status = entry.status,
additions = c.additions or 0,
deletions = c.deletions or 0,
Expand Down
27 changes: 25 additions & 2 deletions lua/fude/preview.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ function M.open_preview(source_win)
base_ref = state.scope_commit_sha .. "^"
end

local content, _ = diff.get_base_content(base_ref, rel_path, local_session and local_session.worktree_root or nil)
-- Renamed files: the base content lives under the old path. Fall
-- back to the new path when the old one is missing at base_ref (the full PR
-- scope reads the base branch tip, which may have dropped the old file).
local cwd = local_session and local_session.worktree_root or nil
local base_path = M.find_base_path(rel_path, state.changed_files)
local content, _ = diff.get_base_content(base_ref, base_path, cwd)
if not content and base_path ~= rel_path then
base_path = rel_path
content, _ = diff.get_base_content(base_ref, base_path, cwd)
end
local source_view = vim.api.nvim_win_call(source_win, vim.fn.winsaveview)
local source_foldenable = vim.wo[source_win].foldenable
local source_foldlevel = vim.wo[source_win].foldlevel
Expand Down Expand Up @@ -86,7 +95,7 @@ function M.open_preview(source_win)
vim.wo[preview_win].signcolumn = "no"
vim.wo[preview_win].winfixwidth = true

pcall(vim.api.nvim_buf_set_name, preview_buf, "[base] " .. rel_path)
pcall(vim.api.nvim_buf_set_name, preview_buf, "[base] " .. base_path)

state.preview_win = preview_win
state.preview_buf = preview_buf
Expand Down Expand Up @@ -138,6 +147,20 @@ function M.open_preview(source_win)
opening = false
end

--- Resolve the path to read the base content from: the old path for a
--- renamed/copied file, the given path otherwise.
--- @param rel_path string repo-relative path of the file being viewed
--- @param changed_files table[]|nil changed files list
--- @return string
function M.find_base_path(rel_path, changed_files)
for _, f in ipairs(changed_files or {}) do
if f.path == rel_path and type(f.previous_path) == "string" and f.previous_path ~= "" then
return f.previous_path
end
end
return rel_path
end

--- Close the preview window and clean up diff mode.
function M.close_preview()
local state = config.state
Expand Down
22 changes: 2 additions & 20 deletions lua/fude/scope.lua
Original file line number Diff line number Diff line change
Expand Up @@ -634,16 +634,7 @@ function M.apply_full_pr_scope(on_done)
state.scope_commit_sha = nil
state.scope_commit_index = nil
state.gitsigns_reset = false
state.changed_files = {}
for _, f in ipairs(files) do
table.insert(state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
state.changed_files = gh_mod.build_changed_files(files)

-- Reset global gitsigns base (clearing commit scope base)
-- Then compute merge_base_sha and apply per-buffer base
Expand Down Expand Up @@ -747,16 +738,7 @@ function M.apply_commit_scope(sha, on_done)
state.scope_commit_sha = sha
state.scope_commit_index = M.find_commit_index(state.pr_commits, sha)
state.gitsigns_reset = false
state.changed_files = {}
for _, f in ipairs(files) do
table.insert(state.changed_files, {
path = f.filename,
status = f.status,
additions = f.additions,
deletions = f.deletions,
patch = f.patch,
})
end
state.changed_files = gh_mod.build_changed_files(files)

-- Update gitsigns base to parent commit
local has_gitsigns, gitsigns = pcall(require, "gitsigns")
Expand Down
29 changes: 29 additions & 0 deletions tests/fude/gh_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,35 @@ describe("parse_viewed_files_response", function()
end)
end)

describe("build_changed_files", function()
it("converts API file objects to changed_files entries", function()
local files = gh.build_changed_files({
{ filename = "lua/a.lua", status = "modified", additions = 3, deletions = 1, patch = "@@ -1 +1 @@" },
})
assert.same({
{ path = "lua/a.lua", status = "modified", additions = 3, deletions = 1, patch = "@@ -1 +1 @@" },
}, files)
end)

it("keeps previous_filename of a renamed file as previous_path", function()
local files = gh.build_changed_files({
{ filename = "lua/new.lua", previous_filename = "lua/old.lua", status = "renamed", additions = 2, deletions = 2 },
})
assert.equals("lua/new.lua", files[1].path)
assert.equals("lua/old.lua", files[1].previous_path)
assert.equals("renamed", files[1].status)
end)

it("ignores a JSON null previous_filename", function()
local files = gh.build_changed_files({ { filename = "a.lua", previous_filename = vim.NIL, status = "modified" } })
assert.is_nil(files[1].previous_path)
end)

it("returns an empty list for no files", function()
assert.same({}, gh.build_changed_files({}))
end)
end)

describe("parse_commit_entries", function()
it("parses commits with full data", function()
local raw = {
Expand Down
23 changes: 20 additions & 3 deletions tests/fude/local_session_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,14 @@ describe("session.parse_name_status", function()
assert.same({ path = "lua/c.lua", status = "removed" }, entries[3])
end)

it("uses the new path for renames", function()
local entries = session.parse_name_status("R100\told.lua\tnew.lua\n")
assert.same({ path = "new.lua", status = "renamed" }, entries[1])
it("uses the new path for renames and keeps the old one", function()
local entries = session.parse_name_status("R087\told.lua\tnew.lua\n")
assert.same({ path = "new.lua", previous_path = "old.lua", status = "renamed" }, entries[1])
end)

it("keeps the source path for copies", function()
local entries = session.parse_name_status("C075\tsrc.lua\tcopy.lua\n")
assert.same({ path = "copy.lua", previous_path = "src.lua", status = "copied" }, entries[1])
end)

it("returns empty for nil / empty output", function()
Expand Down Expand Up @@ -92,6 +97,18 @@ describe("session.build_changed_files", function()
assert.same({ path = "lua/a.lua", status = "modified", additions = 0, deletions = 0 }, files[1])
end)

it("carries the old path of a renamed file", function()
local files =
session.build_changed_files("R087\tlua/old.lua\tlua/new.lua\n", "4\t2\tlua/{old.lua => new.lua}\n", nil)
assert.same({
path = "lua/new.lua",
previous_path = "lua/old.lua",
status = "renamed",
additions = 4,
deletions = 2,
}, files[1])
end)

it("excludes the plugin's own .fude/ store artifacts", function()
local files = session.build_changed_files(
"M\tlua/a.lua\nA\t.fude/reviews/s1.jsonl\n",
Expand Down
37 changes: 37 additions & 0 deletions tests/fude/preview_integration_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,43 @@ describe("preview integration", function()
assert.is_truthy(content:find("New file", 1, true) or content:find("does not exist", 1, true))
end)

it("reads the base content of a renamed file from its old path", function()
local requested = {}
helpers.mock(require("fude.diff"), "get_base_content", function(_, path)
table.insert(requested, path)
if path == "old.lua" then
return "old content\n", nil
end
return nil, "File not found"
end)
config.state.changed_files = { { path = "source.lua", previous_path = "old.lua", status = "renamed" } }

open_for_buffer({ "new content" }, "source.lua")

assert.same({ "old.lua" }, requested)
local lines = vim.api.nvim_buf_get_lines(config.state.preview_buf, 0, -1, false)
assert.same({ "old content" }, lines)
assert.truthy(vim.api.nvim_buf_get_name(config.state.preview_buf):find("[base] old.lua", 1, true))
end)

it("falls back to the new path when the old path is missing at the base", function()
local requested = {}
helpers.mock(require("fude.diff"), "get_base_content", function(_, path)
table.insert(requested, path)
if path == "source.lua" then
return "same name content\n", nil
end
return nil, "File not found"
end)
config.state.changed_files = { { path = "source.lua", previous_path = "old.lua", status = "renamed" } }

open_for_buffer({ "new content" }, "source.lua")

assert.same({ "old.lua", "source.lua" }, requested)
local lines = vim.api.nvim_buf_get_lines(config.state.preview_buf, 0, -1, false)
assert.same({ "same name content" }, lines)
end)

it("records the source buffer the preview was built for", function()
local buf = open_for_buffer({ "line 1" }, "source.lua")
assert.are.equal(buf, config.state.preview_source_buf)
Expand Down
21 changes: 21 additions & 0 deletions tests/fude/preview_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,24 @@ describe("is_preview_current", function()
assert.is_false(preview.is_preview_current(10, true, 1, nil, 1, 20))
end)
end)

describe("find_base_path", function()
it("returns the old path for a renamed file", function()
local files = { { path = "lua/new.lua", previous_path = "lua/old.lua", status = "renamed" } }
assert.equals("lua/old.lua", preview.find_base_path("lua/new.lua", files))
end)

it("returns the path itself for a file without previous_path", function()
local files = { { path = "lua/a.lua", status = "modified" } }
assert.equals("lua/a.lua", preview.find_base_path("lua/a.lua", files))
end)

it("returns the path itself for a file outside changed_files", function()
local files = { { path = "lua/new.lua", previous_path = "lua/old.lua", status = "renamed" } }
assert.equals("lua/other.lua", preview.find_base_path("lua/other.lua", files))
end)

it("handles nil changed_files", function()
assert.equals("lua/a.lua", preview.find_base_path("lua/a.lua", nil))
end)
end)
Loading