diff --git a/.demo/demo.gif b/.demo/demo.gif index 6290607..16fff89 100644 Binary files a/.demo/demo.gif and b/.demo/demo.gif differ diff --git a/.demo/demo.mp4 b/.demo/demo.mp4 index ea5e7fc..19b9e9e 100644 Binary files a/.demo/demo.mp4 and b/.demo/demo.mp4 differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c97810..fcb36a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `dw` in the diff or the panel shows a partly staged file's changes since staging, works as a toggle +- `ds` in the panel or the diff previews the commit: only the staged changes, each diffed against `HEAD`, also works as a toggle +- `s` and `u` act on a hunk the index holds only part of, and the diff winbar tallies staged and partial hunks +- `X` in the diff reverts a file whose only change is its mode, and a binary file + +### Changed + +- A file changed in both the index and the worktree lists once now, under a new `Partial` section, and its diff shows the whole change with the staged lines shaded +- Staged content that can't be shown in the standard diff is marked `!`, openable by `dw` +- `X` on a hunk discards it from the index and the file together, instead of asking you to unstage it first +- `s` and `u` walk the change set once and end with what is left, and `s` stops on a file whose remaining change only `dw` shows until you finish it there or leave it with `]f` +- `s`, `S`, `u` and `U` ask before dropping staged content that neither `HEAD` nor the file on disk holds +- Prompts and notices say plainly what an action loses, or why it did nothing and where to do it instead +- `X` in the panel on a staged edit whose file was then deleted brings it back as staged, instead of resetting it to `HEAD` + +### Fixed + +- A change to a file's final newline showed only a notice. It now shows as a hunk on the last line, with a `¬` in the gutter on whichever side ends without one +- A file whose name holds glob characters, or starts with a digit and a colon, could stage or diff the wrong content, and a symlink diffed the content it points at +- Staging a renamed file from the panel reported "stage failed" although the rename was staged +- Discarding a staged rename or add whose file had been deleted from disk failed +- A file swapped for a symlink refused every staging key in the diff +- `s` in the panel on a staged deletion reported "stage failed" +- `X` in the panel on a submodule asked to discard it and then did nothing. It now says why +- A sidecar that failed to start or kept crashing reported the error twice +- Submitting a review after the PR head moved showed two warnings +- `gh` errors that already said to run `gh auth login` repeated the advice +- Git errors in notices ended with a blank line + +## [0.1.45] — 2026-09-12 + +### Added + - `context` accepts `"full"` in config, matching `:Differ context full`, and it is now the default (`math.huge` still works) - A fold you open in the diff view stays open for the rest of the session, including when you come back to its file diff --git a/README.md b/README.md index 80a3129..9384c59 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ require("differ").setup({ -- diffs toggle_layout = "dl", -- flip between stacked / split + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back + commit_preview = "ds", -- panel/diff: only the staged changes, each as HEAD↔index, and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", @@ -224,6 +226,14 @@ The highlight groups differ defines are listed in [recipes](RECIPES.md#highlight Sources with worktree on the new side (`:Differ`, `:Differ `, `:Differ ...`) also list untracked files. `git diff` can't see them whatever refs you pass it, so differ unions in `git ls-files --others --exclude-standard` to bring them in with the other changes. +### Staging + +Every changed path takes only one row. Its diff spans `HEAD` to the worktree, with the lines already in the index shaded, and each row can either be `Conflicts`, `Staged`, `Unstaged`, `Partial` (not fully staged file) or `Untracked`. In the diff, `s` and `u` stage and unstage a hunk respectively, with repeated presses walking through/back your hunks; `X` discards a hunk on both sides (effectively hard resetting the hunk). Similarly in the panel, `s` and `u` stage and unstage entire rows/files (works on sections, directories and individual rows). `S` and `U` are available in both contexts as stage/unstage-all. + +Certain changes can't be displayed in that span, e.g. a change staged and then put back on disk, or a staged and then further modified hunk. Those hunks carry a `!` in the gutter and the winbar says how many exist in the current file. `dw` opens a view to show what changed since staging, where they show as hunks of their own. `ds` opens a view of **only** the files with staged changes, each diffed against the index, i.e. what is about to be committed, where `u` takes one back out. + +A hunk on a file's last line whose final newline differs between the two sides carries a `¬` in the gutter on the side that ends without one, since the two lines otherwise read the same. + ### Runtime controls | Command | Effect | diff --git a/RECIPES.md b/RECIPES.md index 18e7c82..9f85253 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -78,6 +78,8 @@ Every group is defined with `default = true`, so a `:highlight` of your own wins | `differStagedWordAdd` / `differStagedWordDelete` | the word patch inside a staged line | | `differStagedLine` | a staged line with no add/delete kind (links `CursorLine`) | | `differStagedSign` | the staged-hunk gutter glyph | +| `differHiddenSign` | the `!` gutter glyph on a hunk holding staged content the diff can't show | +| `differNoEol` | the `¬` gutter glyph on a line its side ends on with no newline | ### File panel diff --git a/doc/differ-recipes.txt b/doc/differ-recipes.txt index bdd414e..6f7697d 100644 --- a/doc/differ-recipes.txt +++ b/doc/differ-recipes.txt @@ -137,6 +137,13 @@ STAGING *differ-recipes-highlight-groups-staging* kind (links CursorLine) differStagedSign the staged-hunk gutter glyph + + differHiddenSign the ! gutter glyph on a hunk + holding staged content the diff + can’t show + + differNoEol the ¬ gutter glyph on a line its + side ends on with no newline ----------------------------------------------------------------------- FILE PANEL *differ-recipes-highlight-groups-file-panel* diff --git a/doc/differ.txt b/doc/differ.txt index 0e3d5c2..6be5257 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -12,6 +12,7 @@ Table of Contents *differ-table-of-contents* - Other managers |differ-installation-other-managers| 4. Configuration |differ-configuration| 5. Usage |differ-usage| + - Staging |differ-usage-staging| - Runtime controls |differ-usage-runtime-controls| - PR review |differ-usage-pr-review| - Session and sidecar commands |differ-usage-session-and-sidecar-commands| @@ -200,6 +201,8 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. -- diffs toggle_layout = "dl", -- flip between stacked / split + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back + commit_preview = "ds", -- panel/diff: only the staged changes, each as HEAD↔index, and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", @@ -286,6 +289,31 @@ you pass it, so differ unions in `git ls-files --others --exclude-standard` to bring them in with the other changes. +STAGING *differ-usage-staging* + +Every changed path takes only one row. Its diff spans `HEAD` to the worktree, +with the lines already in the index shaded, and each row can either be +`Conflicts`, `Staged`, `Unstaged`, `Partial` (not fully staged file) or +`Untracked`. In the diff, `s` and `u` stage and unstage a hunk respectively, +with repeated presses walking through/back your hunks; `X` discards a hunk on +both sides (effectively hard resetting the hunk). Similarly in the panel, `s` +and `u` stage and unstage entire rows/files (works on sections, directories and +individual rows). `S` and `U` are available in both contexts as +stage/unstage-all. + +Certain changes can’t be displayed in that span, e.g. a change staged and +then put back on disk, or a staged and then further modified hunk. Those hunks +carry a `!` in the gutter and the winbar says how many exist in the current +file. `dw` opens a view to show what changed since staging, where they show as +hunks of their own. `ds` opens a view of **only** the files with staged +changes, each diffed against the index, i.e. what is about to be committed, +where `u` takes one back out. + +A hunk on a file’s last line whose final newline differs between the two +sides carries a `¬` in the gutter on the side that ends without one, since the +two lines otherwise read the same. + + RUNTIME CONTROLS *differ-usage-runtime-controls* ------------------------------------------------------------------------------ diff --git a/lua/differ/command.lua b/lua/differ/command.lua index bf36989..5274521 100644 --- a/lua/differ/command.lua +++ b/lua/differ/command.lua @@ -14,14 +14,14 @@ end function M.layout(arg) local view = require("differ").active_view() if not view then - return notify("no diff view here", vim.log.levels.WARN) + return notify("no differ view open", vim.log.levels.WARN) end if arg == nil or arg == "" then view:toggle_layout() elseif arg == "stacked" or arg == "split" then view:set_layout(arg) else - notify("layout: expected 'stacked' or 'split'", vim.log.levels.ERROR) + notify("layout: expected 'stacked' or 'split'", vim.log.levels.WARN) end end @@ -30,7 +30,7 @@ end function M.context(arg) local view = require("differ").active_view() if not view then - return notify("no diff view here", vim.log.levels.WARN) + return notify("no differ view open", vim.log.levels.WARN) end if arg == "full" then view:set_context(math.huge) @@ -41,7 +41,7 @@ function M.context(arg) else local n = tonumber(arg) if not n then - return notify("context: expected a number, 'full', '+' or '-'", vim.log.levels.ERROR) + return notify("context: expected a number, 'full', '+' or '-'", vim.log.levels.WARN) end view:set_context(math.max(0, math.floor(n))) end @@ -270,7 +270,10 @@ function M.sidecar(arg) if err then -- a spawn/handshake failure already carries the binary's own stderr; a -- protocol-level one doesn't, so fall back to whatever it last said - local msg = "sidecar: " .. (err.message or err.code) + local msg = err.message or err.code + if not vim.startswith(msg, "sidecar") then -- transport errors already say so + msg = "sidecar: " .. msg + end local exit = sidecar.last_exit() if not msg:find("\n", 1, true) and exit then msg = sidecar.with_tail(msg, exit.stderr) @@ -278,7 +281,7 @@ function M.sidecar(arg) return notify(msg, vim.log.levels.ERROR) end notify( - ("sidecar ok — binary %s, protocol %d"):format(info.binary or "?", info.protocol or 0) + ("sidecar ok: binary %s, protocol %d"):format(info.binary or "?", info.protocol or 0) ) end) end @@ -287,7 +290,7 @@ end ---@param arg string|nil function M.cache(arg) if arg ~= "clear" then - return notify("cache: expected 'clear'", vim.log.levels.ERROR) + return notify("cache: expected 'clear'", vim.log.levels.WARN) end require("differ.sidecar").request("cache_clear", nil, function(err) if err then diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 9147258..84cf111 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -162,6 +162,8 @@ M.defaults = { close = "dc", -- diff/panel/history: end the session (merge, pr or local) toggle_panel = "dd", -- diff/panel: hide/show the file panel sidebar toggle_layout = "dl", -- diff: flip stacked / split + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back + commit_preview = "ds", -- panel/diff: list only what a commit would take, HEAD↔index rows -- merge tool, bound on the result buffer. nav + take-this resolution, -- the result buffer is the real worktree -- file and stays editable, so the whole choose family sits behind diff --git a/lua/differ/git/exec.lua b/lua/differ/git/exec.lua new file mode 100644 index 0000000..0749bf0 --- /dev/null +++ b/lua/differ/git/exec.lua @@ -0,0 +1,128 @@ +-- spawning git, and telling the user (or a stale buffer) about it. every git module +-- runs through here, so the argv prefix and the message shape sit in one place + +local M = {} + +-- every path differ hands git is a real file name, never a glob +M.GIT = { "git", "--literal-pathspecs" } + +---@param msg string +---@param level integer|nil +function M.notify(msg, level) + vim.notify("differ: " .. msg, level or vim.log.levels.INFO) +end + +-- spawn git and wait. vim.system raises when the process can't start, so that comes +-- back as `err`; a nil res with no err is wait() answering nothing at all +---@param cmd string[] +---@param opts table +---@return { code: integer, stdout: string|nil, stderr: string|nil }|nil res, string|nil err +local function run(cmd, opts) + local ok, obj = pcall(vim.system, cmd, opts) + if not ok then + return nil, tostring(obj) + end + return obj:wait() +end + +-- run git in `cwd`: stdout on success, nil + stderr on failure. `text = true` +-- normalises `\r\n`, which is right for plumbing output but would corrupt file content +-- read via `git show`; content reads use git_raw instead +---@param args string[] +---@param cwd string +---@param opts? { timeout?: integer, env?: table } +---@return string|nil stdout, string|nil stderr +function M.git(args, cwd, opts) + local cmd = vim.list_extend({}, M.GIT) + vim.list_extend(cmd, args) + opts = opts or {} + local res, err = run(cmd, { + cwd = cwd, + text = true, + timeout = opts.timeout, + env = opts.env, + }) + if err then + return nil, err -- never started + end + -- a killed process exits 124 with an empty stderr, or answers nothing at all when a + -- child outlives it holding the pipes. both are the budget, and neither says so + if opts.timeout and (not res or res.code == 124) then + return nil, ("git %s timed out after %ds"):format(args[1], opts.timeout / 1000) + end + if not res then + return nil, "git exited without a result" + end + if res.code ~= 0 then + return nil, res.stderr + end + return res.stdout +end + +-- like `git`, but byte-true stdout (CRLF kept): for content reads (`git show` on the +-- index, a rev, a conflict stage), never for plumbing output +---@param args string[] +---@param cwd string +---@return string|nil stdout, string|nil stderr +function M.git_raw(args, cwd) + local cmd = vim.list_extend({}, M.GIT) + vim.list_extend(cmd, args) + local res, err = run(cmd, { cwd = cwd }) + if not res then + return nil, err + end + if res.code ~= 0 then + return nil, res.stderr + end + return res.stdout +end + +-- run git and report a failure where the user can see it. the wrapper owns the message, +-- so a caller branching on the boolean alone can't swallow one +---@param args string[] +---@param cwd string +---@param what string -- names the operation in the message +---@return boolean ok +function M.git_ok(args, cwd, what) + local out, err = M.git(args, cwd) -- nil out is exactly a non-zero exit; stderr can be empty + if not out then + M.notify(M.with_stderr(what .. " failed", err), vim.log.levels.ERROR) + return false + end + return true +end + +-- `lead`, followed by what git said when it said anything +---@param lead string +---@param stderr string|nil +---@return string +function M.with_stderr(lead, stderr) + local said = vim.trim(stderr or "") + if said == "" then + return lead + end + return lead .. ": " .. said +end + +-- strip trailing whitespace from a git output line (e.g. rev-parse, show, log) +---@param s string +---@return string +function M.chomp(s) + return (s:gsub("%s+$", "")) +end + +-- a buffer on a file differ just rewrote keeps the old content until something checks, +-- and a window switch doesn't. checktime leaves unsaved edits alone, so fire it always +---@param root string +---@param relpath string +function M.reload_buffer(root, relpath) + local buf = require("differ.util.buf").find(root .. "/" .. relpath) + if buf and vim.api.nvim_buf_is_loaded(buf) then + -- silent!: the file may be gone entirely (a discarded untracked file) + pcall(vim.api.nvim_buf_call, buf, function() + vim.cmd("silent! checktime") + end) + end +end + +return M diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua new file mode 100644 index 0000000..434d520 --- /dev/null +++ b/lua/differ/git/index.lua @@ -0,0 +1,135 @@ +-- moving content in and out of the index, and the paths one row's ops act on. the +-- whole-file keys and the diff view's hunk staging both write through here + +local exec = require("differ.git.exec") +local git, git_ok, notify = exec.git, exec.git_ok, exec.notify + +local M = {} + +---@param root string +---@param path string +---@return boolean ok +function M.stage(root, path) + return git_ok({ "add", "--", path }, root, "stage") +end + +---@param root string +---@param path string +---@return boolean ok +function M.unstage(root, path) + return git_ok({ "reset", "-q", "HEAD", "--", path }, root, "unstage") +end + +---@param root string +---@return boolean ok +function M.stage_all(root) + return git_ok({ "add", "-A" }, root, "stage all") +end + +---@param root string +---@return boolean ok +function M.unstage_all(root) + return git_ok({ "reset", "-q", "HEAD" }, root, "unstage all") +end + +-- point `path`'s index entry at `text`: a blob write and an entry move, so there's no +-- header to compute and nothing that can half-apply. `text` must already be in index +-- domain (M.read runs the worktree side through the clean filter). the entry keeps the +-- mode it has unless `mode` names another +---@param root string +---@param path string +---@param text string +---@param mode? string +---@return boolean ok +function M.write_index(root, path, text, mode) + local listed = git({ "ls-files", "-s", "--", path }, root) + local held = listed and listed:match("^(%d+)%s") + if not held then + notify(("%s has no index entry to update"):format(path), vim.log.levels.ERROR) + return false + end + mode = mode or held + local hashed = vim.system({ "git", "hash-object", "-w", "--stdin" }, { + cwd = root, + stdin = text, + }):wait() + if hashed.code ~= 0 or not hashed.stdout then + notify( + exec.with_stderr(("staging %s failed"):format(path), hashed.stderr), + vim.log.levels.ERROR + ) + return false + end + local spec = ("%s,%s,%s"):format(mode, exec.chomp(hashed.stdout), path) + return git_ok({ "update-index", "--cacheinfo", spec }, root, "staging " .. path) +end + +-- the paths one entry's staging ops act on: a rename owns both ends of the move, a copy +-- only its new path. a staged rename the worktree then deleted is lettered D +---@param entry differ.FileEntry +---@return string[] +function M.entry_paths(entry) + if (entry.status == "R" or entry.x == "R") and entry.previous_path then + return { entry.path, entry.previous_path } + end + return { entry.path } +end + +-- move every path an entry owns into or out of the index. the panel's file keys and the +-- diff view's whole-file staging share it +---@param root string +---@param entry differ.FileEntry +---@param staged boolean +---@return boolean ok +function M.set_staged(root, entry, staged) + local ok = true + for _, p in ipairs(M.entry_paths(entry)) do + if not staged then + ok = M.unstage(root, p) and ok + elseif p == entry.previous_path then + -- a staged move's old path is in neither the index nor the worktree, which + -- `git add` refuses as a pathspec that matches nothing + local drop = { "rm", "-q", "--cached", "--ignore-unmatch", "--", p } + ok = git_ok(drop, root, "stage") and ok + else + ok = M.stage(root, p) and ok + end + end + return ok +end + +-- the path an entry's index entry sits at: a rename only the worktree has made (` R`) +-- leaves it at the old path, the new one holding an empty intent-to-add placeholder +---@param entry differ.FileEntry +---@return string +function M.index_path(entry) + if entry.y == "R" and entry.previous_path then + return entry.previous_path + end + return entry.path +end + +-- bring a deleted file back: from HEAD for a staged deletion, which only HEAD still +-- has, else from the index, which holds any edits staged before the delete. a staged +-- deletion whose file is on disk would lose that copy, so it refuses +---@param root string +---@param entry differ.FileEntry +---@return boolean +function M.restore_deleted(root, entry) + if entry.kept then + local msg = "X would overwrite %s on disk; u unstages the deletion instead" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end + local cmd = entry.x == "D" and { "checkout", "HEAD", "--", entry.path } + or { "checkout", "--", entry.path } + local _, err = git(cmd, root) + if err then + notify(exec.with_stderr("restore failed", err), vim.log.levels.ERROR) + return false + end + exec.reload_buffer(root, entry.path) + return true +end + +return M diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index bd7ed61..eff7f08 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -5,7 +5,12 @@ -- pure parsing/grammar lives in git/rev.lua; this module only does I/O + wiring local rev = require("differ.git.rev") -local patch = require("differ.git.patch") +local exec = require("differ.git.exec") +local index_ops = require("differ.git.index") +local GIT = exec.GIT +local notify, chomp, reload_buffer = exec.notify, exec.chomp, exec.reload_buffer +local git, git_raw, git_ok = exec.git, exec.git_raw, exec.git_ok +local set_staged, index_path = index_ops.set_staged, index_ops.index_path local log = require("differ.git.log") local watch = require("differ.git.watch") local text_util = require("differ.util.text") @@ -28,12 +33,6 @@ local EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -- git's mode for a submodule entry (a commit pointer) local GITLINK = "160000" ----@param msg string ----@param level integer|nil -local function notify(msg, level) - vim.notify("differ: " .. msg, level or vim.log.levels.INFO) -end - -- how long a network git call may block the editor; `wait()` has no budget of its own M.fetch_timeout_ms = 20000 @@ -68,80 +67,6 @@ local function fetch_opts() return { timeout = M.fetch_timeout_ms, env = { GIT_TERMINAL_PROMPT = "0" } } end --- spawn git and wait. vim.system raises when the process can't start (no git on PATH, --- a cwd that's gone), so the raise comes back as `err`. a nil res with no err is --- wait() answering nothing at all; only the caller knows if a budget explains it ----@param cmd string[] ----@param opts table ----@return { code: integer, stdout: string|nil, stderr: string|nil }|nil res, string|nil err -local function run(cmd, opts) - local ok, obj = pcall(vim.system, cmd, opts) - if not ok then - return nil, tostring(obj) - end - return obj:wait() -end - --- run git in `cwd`. returns stdout on success, or nil + stderr on failure. --- `text = true` normalises `\r\n` to `\n` in stdout, which is right for plumbing --- output (status/numstat/rev-parse/name-status/...) but would corrupt file --- content read via `git show`; content reads use git_raw instead ----@param args string[] ----@param cwd string ----@param opts? { timeout?: integer, env?: table } ----@return string|nil stdout, string|nil stderr -local function git(args, cwd, opts) - local cmd = { "git" } - vim.list_extend(cmd, args) - opts = opts or {} - local res, err = run(cmd, { - cwd = cwd, - text = true, - timeout = opts.timeout, - env = opts.env, - }) - if err then - return nil, err -- never started - end - -- a killed process exits 124 with an empty stderr, or answers nothing at all when - -- a child outlives it holding the pipes (a fetch's transport helper does). both are - -- the budget, and neither says so itself - if opts.timeout and (not res or res.code == 124) then - return nil, ("git %s timed out after %ds"):format(args[1], opts.timeout / 1000) - end - if not res then - return nil, "git exited without a result" - end - if res.code ~= 0 then - return nil, res.stderr - end - return res.stdout -end - --- like `git`, but without `text = true`: stdout comes back byte-true (CRLF kept --- intact) instead of newline-normalised. use for content-bearing reads (`git --- show` on the index/a rev/a conflict stage), never for plumbing output ----@param args string[] ----@param cwd string ----@return string|nil stdout, string|nil stderr -local function git_raw(args, cwd) - local cmd = { "git" } - vim.list_extend(cmd, args) - local res, err = run(cmd, { cwd = cwd }) - if not res then - return nil, err - end - if res.code ~= 0 then - return nil, res.stderr - end - return res.stdout -end - --- strip trailing whitespace from a git output line (e.g. rev-parse, show, log) -local function chomp(s) - return (s:gsub("%s+$", "")) -end - -- repo root containing `path` (a file or directory), or nil if not in a repo ---@param path string ---@return string|nil @@ -240,6 +165,11 @@ end ---@param abs string ---@return string|nil local function read_file(abs) + local uv = vim.uv or vim.loop + local st = uv.fs_lstat(abs) + if st and st.type == "link" then + return uv.fs_readlink(abs) -- git stores a symlink as its target path + end if vim.fn.filereadable(abs) == 0 then return nil end @@ -290,13 +220,14 @@ local function as_staged(root, relpath, data) dst:close() end local env = { GIT_INDEX_FILE = tmp } - local add = vim.system({ "git", "add", "--", relpath }, { cwd = root, env = env }):wait() + local add_cmd = vim.list_extend(vim.list_extend({}, GIT), { "add", "--", relpath }) + local add = vim.system(add_cmd, { cwd = root, env = env }):wait() if add.code ~= 0 then os.remove(tmp) return data end -- raw read, no text=true: that would strip the CRs git chose to keep - local show = vim.system({ "git", "show", ":" .. relpath }, { cwd = root, env = env }):wait() + local show = vim.system({ "git", "show", ":0:" .. relpath }, { cwd = root, env = env }):wait() os.remove(tmp) if show.code ~= 0 or not show.stdout then return data @@ -318,8 +249,9 @@ function M.read(ref, root, relpath) local data = read_file(root .. "/" .. relpath) return data and as_staged(root, relpath, data) or nil end - -- index (stage 0) is `:path`; a rev is `:path` - local spec = (ref.kind == "index" and ":" or (ref.rev .. ":")) .. relpath + -- a rev is `:path`; the index is `:0:path`, since `:path` reads a leading + -- `1:` as a stage number + local spec = (ref.kind == "index" and ":0:" or (ref.rev .. ":")) .. relpath return git_raw({ "show", spec }, root) -- nil if the path is absent in that tree end @@ -657,159 +589,211 @@ function M.file_entries(source, root) return out end --- working-tree status as panel sections: Staged / Unstaged / Untracked --- (slice B). git status compares HEAD/index/worktree, so it only models the --- default HEAD-vs-worktree source; rev-pair sources use file_entries instead. --- a file edited in both index and worktree (e.g. "MM") appears in both Staged --- (X status, HEAD↔index counts) and Unstaged (Y status, index↔worktree counts). --- empty sections are dropped by the caller +-- a file's three diffs over texts already read: HEAD↔worktree to show, and the two +-- real pairs its marks come from ---@param root string ----@return differ.panel.Section[] sections, string|nil err -function M.status_sections(root) - local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) - local entries = rev.parse_status(out or "") - local staged_counts = numstat({ "--cached" }, root) - local unstaged_counts = numstat({}, root) - local staged, unstaged, untracked = {}, {}, {} - for _, s in ipairs(entries) do - if s.x == "?" then - untracked[#untracked + 1] = { - path = s.path, - status = "?", - additions = untracked_additions(root, s.path), - deletions = 0, - staged = false, - } - else - -- previous_path only belongs to whichever pair carries the rename: - -- an "RM" file is renamed HEAD↔index but plain-modified index↔worktree - if s.x ~= " " then - local c = staged_counts[s.path] or {} - staged[#staged + 1] = { - path = s.path, - status = s.x, - additions = c.additions or 0, - deletions = c.deletions or 0, - staged = true, - previous_path = (s.x == "R" or s.x == "C") and s.previous_path or nil, - } - end - if s.y ~= " " then - local c = unstaged_counts[s.path] or {} - unstaged[#unstaged + 1] = { - path = s.path, - status = s.y, - additions = c.additions or 0, - deletions = c.deletions or 0, - staged = false, - previous_path = (s.y == "R" or s.y == "C") and s.previous_path or nil, - } - end - end - end - local sections = { - { title = "Staged", entries = staged }, - { title = "Unstaged", entries = unstaged }, - { title = "Untracked", entries = untracked }, - } - return sections, err +---@param path string +---@param head string +---@param index string +---@param work string +---@return differ.DiffModel union, differ.DiffModel cached, differ.DiffModel unstaged +local function union_pairs(root, path, head, index, work) + local build = require("differ.model.diff").build + local function pair(old_rev, new_rev, old_text, new_text) + return build({ + path = path, + old_rev = old_rev, + new_rev = new_rev, + old_text = old_text, + new_text = new_text, + root = root, + }) + end + return pair("HEAD", "WORKTREE", head, work), + pair("HEAD", "INDEX", head, index), + pair("INDEX", "WORKTREE", index, work) end --- file-level staging ops driven from the panel (slice C); each is whole-file --- and operates on the repo root. hunk-level staging stays in the diff view - --- run git and report a failure where the user can see it, returning whether it landed. --- these wrappers own their own message so callers only ever branch on the boolean: a --- swallowed failure would leave the panel and the staged marks describing a state git --- never reached ----@param args string[] ----@param cwd string ----@param what string -- names the operation in the message ----@return boolean ok -local function git_ok(args, cwd, what) - local out, err = git(args, cwd) -- nil out is exactly a non-zero exit; stderr can be empty - if not out then - notify(("%s failed: %s"):format(what, err or ""), vim.log.levels.ERROR) - return false +-- a row's three diffs, read fresh, or nil when the index can't be read. HEAD is read at +-- a rename's old path +---@param root string +---@param entry differ.FileEntry +---@return differ.DiffModel|nil union, differ.DiffModel|nil cached, differ.DiffModel|nil unstaged +function M.union_models(root, entry) + local at_head = entry.previous_path or entry.path + local index = M.read(INDEX, root, index_path(entry)) + if not index then + return nil end - return true + return union_pairs( + root, + entry.path, + M.read(HEAD, root, at_head) or "", + index, + M.read(WORKTREE, root, entry.path) or "" + ) end ----@param root string ----@param path string ----@return boolean ok -function M.stage(root, path) - return git_ok({ "add", "--", path }, root, "stage") +-- a porcelain entry's section and the letter its row shows. the section comes from +-- which of the index (X) and the worktree (Y) hold a change; the letter is the change +-- the row's HEAD↔worktree diff shows +---@param s { x: string, y: string } +---@return string section, string status +local function row_of(s) + if s.x == "?" then + return "Untracked", "?" + end + if s.x == "U" or s.y == "U" or (s.x == s.y and (s.x == "A" or s.x == "D")) then + return "Conflicts", "U" + end + if s.y == " " then + return "Staged", s.x + end + if s.x == " " then + return "Unstaged", s.y + end + if s.y == "D" and s.x ~= "A" then + return "Partial", "D" + end + return "Partial", s.x end +-- the worktree's porcelain entries, and the set of paths it lists as untracked ---@param root string ----@param path string ----@return boolean ok -function M.unstage(root, path) - return git_ok({ "reset", "-q", "HEAD", "--", path }, root, "unstage") +---@return differ.git.StatusEntry[] entries, table untracked, string|nil err +local function porcelain(root) + local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) + local entries = rev.parse_status(out or "") + local untracked = {} + for _, s in ipairs(entries) do + if s.x == "?" then + untracked[s.path] = true + end + end + return entries, untracked, err end ----@param root string ----@return boolean ok -function M.stage_all(root) - return git_ok({ "add", "-A" }, root, "stage all") +-- what a row leaves to review, from the two status letters: a conflict is the merge +-- tool's, and the panel's review walk steps past it +---@param x string -- index side +---@param y string -- worktree side +---@param status string +---@return differ.panel.Review +local function review_state(x, y, status) + if status == "U" then + return "conflict" + end + if x == " " or x == "?" then + return "unstaged" + end + if y == " " then + return "staged" + end + return "partial" end ----@param root string ----@return boolean ok -function M.unstage_all(root) - return git_ok({ "reset", "-q", "HEAD" }, root, "unstage all") +-- a panel row for porcelain entry `s`, lettered `status` and counted from `counts` +---@param s differ.git.StatusEntry +---@param status string +---@param counts table +---@param untracked table +---@return differ.FileEntry +local function status_row(s, status, counts, untracked) + local c = counts[s.path] or {} + return { + path = s.path, + status = status, + review = review_state(s.x, s.y, status), + additions = c.additions or 0, + deletions = c.deletions or 0, + x = s.x, + y = s.y, + previous_path = s.previous_path, + kept = (s.x == "D" and untracked[s.path]) or nil, + } end --- the paths one entry's staging ops act on: a rename owns both ends of the move, a copy --- only its new path ----@param entry differ.FileEntry ----@return string[] -local function entry_paths(entry) - if entry.status == "R" and entry.previous_path then - return { entry.path, entry.previous_path } +-- working-tree status as panel sections: Staged / Partial / Unstaged / Untracked +-- (slice B). git status compares HEAD/index/worktree, so it only models the +-- default HEAD-vs-worktree source; rev-pair sources use file_entries instead. +-- every changed path takes one row, counted against HEAD since that is the diff it +-- opens. a staged deletion counts against the index: porcelain lists a file removed +-- from the index but still on disk twice (`D ` and `??`), and its one row is the +-- deletion, marked `kept`. empty sections are dropped by the caller +---@param root string +---@return differ.panel.Section[] sections, string|nil err +function M.status_sections(root) + local entries, untracked, err = porcelain(root) + local staged_counts = numstat({ "--cached" }, root) + local head_counts = numstat({ "HEAD" }, root) + local removed = {} + for _, s in ipairs(entries) do + if s.x == "D" then + removed[s.path] = true + end end - return { entry.path } + local rows = { Conflicts = {}, Staged = {}, Partial = {}, Unstaged = {}, Untracked = {} } + for _, s in ipairs(entries) do + if not (s.x == "?" and removed[s.path]) then + local section, status = row_of(s) + local counts = s.x == "D" and staged_counts or head_counts + local row = status_row(s, status, counts, untracked) + if status == "?" then + row.additions = untracked_additions(root, s.path) + end + local list = rows[section] + list[#list + 1] = row + end + end + local sections = { + { title = "Conflicts", entries = rows.Conflicts }, + { title = "Staged", entries = rows.Staged }, + { title = "Partial", entries = rows.Partial }, + { title = "Unstaged", entries = rows.Unstaged }, + { title = "Untracked", entries = rows.Untracked }, + } + return sections, err end --- move every path an entry owns into or out of the index. the panel's file keys and the --- diff view's whole-file staging share it +-- the rows a commit would take, as one section: every path the index changes, counted +-- and lettered against HEAD. conflicts and untracked files hold nothing to commit ---@param root string ----@param entry differ.FileEntry ----@param staged boolean ----@return boolean ok -local function set_staged(root, entry, staged) - local ok = true - for _, p in ipairs(entry_paths(entry)) do - if staged then - ok = M.stage(root, p) and ok - else - ok = M.unstage(root, p) and ok +---@return differ.panel.Section[] sections, string|nil err +function M.staged_sections(root) + local entries, untracked, err = porcelain(root) + local counts = numstat({ "--cached" }, root) + local rows = {} + for _, s in ipairs(entries) do + local section = row_of(s) + if section ~= "Untracked" and section ~= "Conflicts" and s.x ~= " " then + rows[#rows + 1] = status_row(s, s.x, counts, untracked) end end - return ok + return { { title = "Staged changes", entries = rows } }, err end --- apply a single-hunk patch, atomically. `--unidiff-zero` because the patch carries --- no context (built straight from the hunk model); `--reverse` undoes rather than --- applies. `target` picks the side written: the index for hunk staging, the worktree --- for hunk revert. never `--index` (both at once), which checks the whole path is in --- sync and so refuses on any file that has changes on the other side, hunk-unrelated --- ones included; a revert that must reach both composes two calls instead. git apply --- is atomic, so a non-applying patch fails cleanly with stderr rather than --- half-writing. returns ok + git's stderr on failure +-- file-level staging ops driven from the panel (slice C); each is whole-file +-- and operates on the repo root. hunk-level staging stays in the diff view + +-- the index ops the panel's file keys and the merge tool drive +M.stage, M.unstage = index_ops.stage, index_ops.unstage +M.stage_all, M.unstage_all = index_ops.stage_all, index_ops.unstage_all + +-- reverse-apply a single-hunk patch to the worktree, atomically. `--unidiff-zero` +-- because the patch carries no context (built straight from the hunk model). never +-- `--index` (index and worktree at once), which checks the whole path is in sync and so +-- refuses on any file that has changes on the other side, hunk-unrelated ones included; +-- a revert that must reach both writes the index separately. git apply is atomic, so a +-- non-applying patch fails cleanly with stderr rather than half-writing. the index is +-- never patched: staging works out the content it should hold and writes it whole ---@param root string ----@param text string -- the unified diff to apply ----@param reverse boolean ----@param target? "index"|"worktree" -- default "index" +---@param text string -- the unified diff to reverse +---@param check? boolean -- only test that it applies, writing nothing ---@return boolean ok, string|nil err -function M.apply_patch(root, text, reverse, target) - local cmd = { "git", "apply", "--unidiff-zero", "--whitespace=nowarn" } - if (target or "index") == "index" then - cmd[#cmd + 1] = "--cached" - end - if reverse then - cmd[#cmd + 1] = "--reverse" +function M.revert_patch(root, text, check) + local cmd = { "git", "apply", "--unidiff-zero", "--whitespace=nowarn", "--reverse" } + if check then + cmd[#cmd + 1] = "--check" end cmd[#cmd + 1] = "-" local res = vim.system(cmd, { cwd = root, stdin = text, text = true }):wait() @@ -819,41 +803,26 @@ function M.apply_patch(root, text, reverse, target) return true end --- an open buffer on a file differ just rewrote keeps showing the old content until --- something checks: a window switch doesn't, so a revert or discard would sit next to --- a stale window. checktime reloads it, and leaves a buffer with unsaved edits alone --- (nvim warns rather than clobbering), so this is safe to fire unconditionally ----@param root string ----@param relpath string -local function reload_buffer(root, relpath) - local buf = require("differ.util.buf").find(root .. "/" .. relpath) - if buf and vim.api.nvim_buf_is_loaded(buf) then - -- silent!: the file may be gone entirely (a discarded untracked file) - pcall(vim.api.nvim_buf_call, buf, function() - vim.cmd("silent! checktime") - end) - end -end - -- git_ok's counterpart for the one discard path that isn't a git call ---@param abs string ---@return boolean ok local function remove_ok(abs) local ok, err = os.remove(abs) - if not ok then - notify(("discard failed: %s"):format(err or ""), vim.log.levels.ERROR) + -- a file already gone from disk is as discarded as one removed here + if not ok and (vim.uv or vim.loop).fs_lstat(abs) then + notify(exec.with_stderr("discard failed", err), vim.log.levels.ERROR) return false end return true end --- the entry's status as git reports it now, or nil when the path has no changes left. --- the panel's entries are a snapshot taken before the confirm, and the same side of --- the file the entry came from is the side to re-read +-- the entry's porcelain line as git reports it now, or nil when the path has no changes +-- left. the panel's entries are a snapshot, which an op or the confirm before it can +-- leave behind ---@param root string ---@param entry differ.FileEntry ----@return string|nil status -local function live_status(root, entry) +---@return differ.git.StatusEntry|nil +local function live_row(root, entry) local args = { "status", "--porcelain=v1", "-z", "-uall", "--", entry.path } -- git pairs a rename only with both paths in the pathspec; with one it reports an add if entry.previous_path then @@ -861,23 +830,44 @@ local function live_status(root, entry) end local out = git(args, root) for _, s in ipairs(rev.parse_status(out or "")) do - if s.path == entry.path then - return s.x == "?" and "?" or (entry.staged and s.x or s.y) + -- a kept deletion's copy on disk is its own `??` line, and not this row's status + if s.path == entry.path and not (s.x == "?" and entry.kept) then + return s end end return nil end --- the path's `diff --raw` line under `args`'s pair, or nil when the pair no longer --- lists it at all +-- the entry's status as git reports it now, read from the side of the file the entry +-- came from, or nil when the path has no changes left +---@param root string +---@param entry differ.FileEntry +---@param preview? boolean -- the entry is lettered by its index side, as the commit preview lists it +---@return string|nil status +local function live_status(root, entry, preview) + local s = live_row(root, entry) + if not s then + return nil + end + if preview then + return s.x + end + return select(2, row_of(s)) +end + +-- the entry's `diff --raw` line under `args`'s pair, or nil when the pair no longer +-- lists it at all. git pairs a rename only with both paths in the pathspec ---@param root string ---@param args string[] ----@param path string +---@param entry differ.FileEntry ---@return string|nil -local function raw_line(root, args, path) +local function raw_line(root, args, entry) local full = { "diff", "--raw" } vim.list_extend(full, args) - vim.list_extend(full, { "--", path }) + vim.list_extend(full, { "--", entry.path }) + if entry.previous_path then + full[#full + 1] = entry.previous_path + end local out = git(full, root) if not out or chomp(out) == "" then return nil @@ -894,17 +884,17 @@ end ---@return string|nil local function empty_notice(root, entry, model, args) local reason = require("differ.model.diff").empty_reason(model) - if reason == "eol_added" then - return "Final newline added" - elseif reason == "eol_removed" then - return "Final newline removed" - end local empty_or_not = reason == "empty" and "Empty file" or "No content change" if entry.status == "?" then return live_status(root, entry) == entry.status and empty_or_not or nil end - local out = raw_line(root, args, entry.path) + local out = raw_line(root, args, entry) if not out then + -- a row changed on both sides whose worktree is back to HEAD: the staged change + -- is real, it just has no HEAD↔worktree diff to show + if entry.x ~= " " and entry.y ~= " " and raw_line(root, { "--cached" }, entry) then + return "Staged changes the worktree has put back" + end return nil end if (entry.status == "R" or entry.status == "C") and entry.previous_path then @@ -927,33 +917,78 @@ local function empty_notice(root, entry, model, args) return empty_or_not end +-- why dw has no local view for `entry`: a staged edit or move then deleted on disk has +-- one change on top, its deletion, which the whole change already stages +---@param entry differ.FileEntry +---@return string +local function no_local_reason(entry) + if entry.y == "D" and (entry.x == "M" or entry.x == "R") then + return ("%s's only local change is its deletion, which s stages here"):format(entry.path) + end + return "only a partly staged file has a local view" +end + +-- whether X on a worktree row brings the file back as staged: an edit or move staged +-- and then deleted on disk, whose staged version nothing else holds +---@param entry differ.FileEntry +---@return boolean +local function restores_staged(entry) + return entry.y == "D" and (entry.x == "M" or entry.x == "R") +end + -- discard a file's changes: untracked or a staged-add drops the file (unstaging --- first if needed); anything tracked in HEAD reverts index + worktree to HEAD. +-- first if needed); a staged edit or move then deleted on disk comes back as staged; +-- anything else tracked in HEAD reverts index + worktree to HEAD. -- destructive, so the panel confirms before calling this. confirm() drains scheduled -- callbacks while it blocks, so the status the caller holds can have moved under the -- prompt: the three branches differ in whether they delete the file, so a status that -- no longer matches refuses rather than picking by the stale one ---@param root string ---@param entry differ.FileEntry +---@param preview? boolean -- the entry comes from the commit preview's listing ---@return boolean ok -function M.discard(root, entry) - if live_status(root, entry) ~= entry.status then +function M.discard(root, entry, preview) + if live_status(root, entry, preview) ~= entry.status then notify( - ("discard skipped: %s changed since the prompt"):format(entry.path), + ("discard skipped: %s isn't as the list showed it any more"):format(entry.path), vim.log.levels.WARN ) return false end + -- restoring a staged deletion from HEAD would overwrite the copy still on disk + if entry.kept then + local msg = "discard skipped: X would overwrite %s on disk; u unstages the deletion instead" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end + if not preview then + if restores_staged(entry) then + return index_ops.restore_deleted(root, entry) + end + -- checkout moves the commit a submodule records, never the checkout inside it + local old_mode, new_mode = rev.parse_raw_modes(raw_line(root, { "HEAD" }, entry) or "") + if old_mode == GITLINK or new_mode == GITLINK then + local msg = "discard skipped: X can't move the checkout inside the submodule %s" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end + end local abs = root .. "/" .. entry.path + -- an add, copy or rename the worktree then deleted is lettered D in the whole list, + -- but its new path is unknown to HEAD, so it discards by what the index holds + local kind = entry.status + if entry.x == "A" or entry.x == "C" or entry.x == "R" then + kind = entry.x + end local ok - if entry.status == "?" then + if kind == "?" then ok = remove_ok(abs) - elseif entry.status == "A" or entry.status == "C" then + elseif kind == "A" or kind == "C" then -- unstage the add before dropping the file, and don't drop it if that failed: -- the index would keep an add for a file no longer on disk. a copy's source is -- untouched, so its new path is an add ok = git_ok({ "reset", "-q", "HEAD", "--", entry.path }, root, "discard") and remove_ok(abs) - elseif entry.status == "R" and entry.previous_path then + elseif kind == "R" and entry.previous_path then -- undoing a move: the old path comes back from HEAD, the new one goes like an add ok = git_ok({ "checkout", "HEAD", "--", entry.previous_path }, root, "discard") and git_ok({ "reset", "-q", "HEAD", "--", entry.path }, root, "discard") @@ -1128,44 +1163,72 @@ function M.panel(opts) end local branch = head_branch(root) -- once per source, for the buffer statuslines - -- model_for picks the (old, new) pair per entry: working-tree sections diff by - -- the entry's staged flag (staged = HEAD↔index, else index↔worktree), while a - -- rev-pair list diffs every entry against the one resolved source. `actions` - -- (file-level staging) is only meaningful for the worktree-status source + -- model_for picks the (old, new) pair per entry: a working-tree row diffs + -- HEAD↔worktree, which staging never moves, while a rev-pair list diffs every entry + -- against the one resolved source. a staged deletion diffs HEAD↔index, since its + -- worktree copy is a row of its own. `actions` (file-level staging) is only + -- meaningful for the worktree-status source + local preview = false -- ds: the panel lists only what a commit would take local sections, model_for, raw_args_for, actions local list_err ---@type string|nil -- git's own words when the listing failed + local staging_ops ---@type table -- assigned below; the hooks read it at call time if is_worktree_status(source) then sections, list_err = M.status_sections(root) model_for = function(entry) - local s = entry.staged and { old = HEAD, new = INDEX } - or { old = INDEX, new = WORKTREE } + local s = { old = HEAD, new = (preview or entry.x == "D") and INDEX or WORKTREE } -- re-read HEAD per build so a branch switch under an open panel updates -- the synthetic buffer's statusline label, not just the diff content return M.model(s, root, entry, head_branch(root)) end - -- the entry's own pair as `diff` args: staged is HEAD↔index, unstaged - -- index↔worktree + -- the entry's own pair as `diff` args raw_args_for = function(entry) - return entry.staged and { "--cached" } or {} + return (preview or entry.x == "D") and { "--cached" } or { "HEAD" } end actions = { stage = function(entry) + if preview then + notify("the commit preview only unstages: ds goes back", vim.log.levels.WARN) + return false + end + -- a Staged row has nothing on disk to take; a deletion kept on disk + -- would take its untracked copy back in + if entry.y == " " then + return false + end set_staged(root, entry, true) end, unstage = function(entry) set_staged(root, entry, false) end, + stage_drops = function(entry) + return not preview and staging_ops.stage_drops(entry) + end, + drop_prompts = require("differ.git.staging").DROP_PROMPTS, + unstage_drops = function(entry) + return staging_ops.unstage_drops(entry, function() + return M.model({ old = HEAD, new = WORKTREE }, root, entry) + end) + end, stage_all = function() + if preview then + notify("the commit preview only unstages: ds goes back", vim.log.levels.WARN) + return false + end M.stage_all(root) end, unstage_all = function() M.unstage_all(root) end, discard = function(entry) - M.discard(root, entry) + M.discard(root, entry, preview) + end, + discard_prompt = function(entry) + if not preview and restores_staged(entry) then + return ("Restore %s as staged?"):format(entry.path) + end end, reload = function() - local live = M.status_sections(root) + local live = preview and M.staged_sections(root) or M.status_sections(root) return (nonempty_sections(live)) end, } @@ -1186,28 +1249,40 @@ function M.panel(opts) -- an empty list and a failed one look the same from here, and a mistyped -- revspec only ever lands as the second: report what git said if list_err then - return notify(chomp(list_err), vim.log.levels.ERROR) + return notify(exec.with_stderr("couldn't list changes", list_err), vim.log.levels.ERROR) end - return notify("no changes for this source") + return notify("no changes to show") end local view ---@type differ.View|nil -- the single diff view the panel drives local panel ---@type differ.Panel|nil -- forward ref so staging can refresh it local watcher ---@type differ.git.Watcher|nil -- fs watcher, set for worktree panels - local on_edit_unstage ---@type fun(path: string)|nil -- assigned below; passed to the view - local settle_pair ---@type fun(): boolean -- assigned below; the staging follow hook + local retarget_view ---@type fun(outside: boolean): boolean -- assigned below + local set_preview ---@type fun(on: boolean) -- assigned below - -- hunk-level staging. content edits patch by hunk; anything with no lines to patch - -- sets `whole_file`. the view keeps its diff frozen, so `apply` patches one hunk: - -- forward stages, `reverse` unstages, `offset` shifts past the hunks staged before it + -- hunk-level staging. content edits stage by hunk; anything with no lines to stage + -- sets `whole_file`. `apply` stages one hunk, or unstages it with `reverse` local stageable = is_worktree_status(source) local active_entry ---@type differ.FileEntry|nil -- the file the view currently shows - -- a cheap fingerprint of the diff's inputs: HEAD + porcelain status (the index - -- side) + the shown file's mtime/size (the worktree side). external refreshes act - -- only when it moved, so a stray event doesn't re-source over an in-progress - -- in-differ staging session, and a differ stage records it so the index write it - -- caused doesn't read as outside. content-aware so a worktree edit counts too + -- a cheap fingerprint of the diff's inputs: HEAD + porcelain status + the shown + -- file's index blobs (the index side) + its mtime/size (the worktree side). external + -- refreshes act only when it moved, so a stray event doesn't re-source over an + -- in-progress in-differ staging session, and a differ stage records it so the index + -- write it caused doesn't read as outside + -- one row's part of it: its index blobs, and its file's mtime and size. a partial + -- stage that leaves the row MM moves only the blob + ---@param entry differ.FileEntry + ---@return string + local function row_signature(entry) + local blobs = { "ls-files", "-s", "--", entry.path, entry.previous_path } + local sig = git(blobs, root) or "" + local st = (vim.uv or vim.loop).fs_stat(root .. "/" .. entry.path) + if st and st.mtime then + sig = sig .. "\0" .. st.mtime.sec .. "." .. st.mtime.nsec .. ":" .. st.size + end + return sig + end local function git_signature() if not stageable then return "" @@ -1216,13 +1291,55 @@ function M.panel(opts) .. "\0" .. (git({ "status", "--porcelain=v1", "-z", "-uall" }, root) or "") if active_entry then - local st = (vim.uv or vim.loop).fs_stat(root .. "/" .. active_entry.path) - if st and st.mtime then - sig = sig .. "\0" .. st.mtime.sec .. "." .. st.mtime.nsec .. ":" .. st.size - end + sig = sig .. "\0" .. row_signature(active_entry) end return sig end + + -- rows the review walk stepped off with nothing left for its key, by walk (false: s) + -- then path, each with HEAD and the row's signature as they were. any change to + -- either puts the row back in the walk + local passed = { [false] = {}, [true] = {} } ---@type table> + ---@param entry differ.FileEntry + ---@return string + local function passed_key(entry) + return (git({ "rev-parse", "HEAD" }, root) or "") .. "\0" .. row_signature(entry) + end + ---@type differ.panel.ReviewHooks + local review_hooks = { + accept = function(entry, staged) + -- the preview and the local view aren't the whole change: what they show done + -- says nothing about the row + local badge = view and view.staging and view.staging.badge + if preview or badge == "LOCAL" then + return + end + passed[staged][entry.path] = passed_key(entry) + end, + accepted = function(entry, staged) + local was = passed[staged][entry.path] + if not was then + return false + end + if was == passed_key(entry) then + return true + end + passed[staged][entry.path] = nil + return false + end, + has_local = function(entry) + return staging_ops.partly_staged(entry) + end, + -- the paths index↔worktree lists, in one read for the whole list. a row left + -- with only a move isn't listed, so dw opens empty on it + local_paths = function() + local out = {} + for _, path in ipairs(rev.parse_paths(git({ "diff", "--name-only", "-z" }, root) or "")) do + out[path] = true + end + return out + end, + } local last_sig = git_signature() -- record the state the list now reflects, so the next external event doesn't read an -- in-differ op as an outside change and re-source over the in-place staged marks. @@ -1239,165 +1356,92 @@ function M.panel(opts) record_state() end end - -- throw one hunk away rather than move it between index and worktree. an unstaged - -- hunk exists only in the worktree, so a single reverse apply is the whole job. a - -- staged one is in the index and, unless it was edited since, the worktree too, so - -- it takes both: index first, because a worktree copy that won't take the patch - -- then leaves the file merely unstaged (a state the panel already models) instead - -- of holding a change the index no longer has. returns whether the model's new - -- side moved, which is what tells the caller to re-source - ---@param model differ.DiffModel - ---@param hunk differ.Hunk - ---@param offset integer -- index-side only; see differ.view.Staging - ---@return boolean - local function revert_hunk(model, hunk, offset) - -- a revert patches whichever file the model's new side was read from (the - -- worktree, or the index on a staged diff), so it's in new-side coordinates - ---@param off integer - local function reverse_patch(off) - return patch.hunk(model.path, hunk, model.old_text, model.new_text, off, "new") - end - if model.new_rev ~= INDEX.label then - local ok, err = M.apply_patch(root, reverse_patch(0), true, "worktree") - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - end - reload_buffer(root, model.path) - return ok + + -- the row-level staging ops. they read and write git, and call back here to reload + -- the list and re-source the view + staging_ops = require("differ.git.staging").new({ + root = root, + stageable = stageable, + refresh = function() + refresh_panel() + end, + retarget = function(outside) + retarget_view(outside) + end, + preview_off = function() + set_preview(false) + end, + current_view = function() + return view + end, + }) + + local show_local ---@type fun(entry: differ.FileEntry) + + -- swap the open view onto `entry`'s local view, index↔worktree. back goes through + -- retarget_view, which re-reads the row, since staging here can move it to another + -- section + ---@param entry differ.FileEntry + show_local = function(entry) + if not (view and view:is_open()) then + return end - local ok, err = M.apply_patch(root, reverse_patch(offset), true, "index") - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - return false + -- a reopen after an op here reads a row the op may have finished: with nothing + -- staged or nothing on top any more, the whole change takes over + local live = live_row(root, entry) + if live then + entry = vim.tbl_extend("force", entry, { x = live.x, y = live.y }) + end + if not (live and staging_ops.partly_staged(entry)) then + retarget_view(false) + return end - -- unstaging shifts the index but never the worktree, so the worktree's copy - -- still sits where the model says and this half needs no offset - if not M.apply_patch(root, reverse_patch(0), true, "worktree") then - local msg = - "%s: reverted from the index; the worktree copy changed since, so it stays unstaged" - notify(msg:format(model.path), vim.log.levels.WARN) + local focus_line, focus_col = view:cursor_new_line() + local at_index = index_path(entry) + local prev = nil ---@type string|nil + if at_index ~= entry.path then + prev = at_index end - reload_buffer(root, model.path) - return true -- the index half landed either way - end - - -- bring a deleted file back. which side it comes from is fixed by which diff is on - -- screen: a staged deletion is recorded in the index, so only HEAD still has the - -- content; an unstaged one is still in the index, and that's what its diff compares - -- against, so restoring from HEAD instead would silently drop edits staged before - -- the delete - ---@param entry differ.FileEntry - ---@return boolean - local function restore_deleted(entry) - local cmd = entry.staged and { "checkout", "HEAD", "--", entry.path } - or { "checkout", "--", entry.path } - local _, err = git(cmd, root) - if err then - notify(("restore failed: %s"):format(err), vim.log.levels.ERROR) - return false + local file = { path = entry.path, status = entry.status, previous_path = prev } + local model = M.model({ old = INDEX, new = WORKTREE }, root, file, head_branch(root)) + if #model.hunks == 0 and not model.binary then + model.notice = empty_notice(root, entry, model, {}) or "No local content change" end - reload_buffer(root, entry.path) - return true + local staging = staging_ops.for_local(entry, model, function() + show_local(entry) + end) + local function back() + retarget_view(false) + end + staging.badge, staging.toggle_local, staging.leave = "LOCAL", back, back + local focus = focus_line and { focus_line = focus_line, focus_col = focus_col } or nil + view:set_source(model, staging, focus) + last_sig = git_signature() end + -- a row whose staged change the worktree has put back, or a staged add whose file is + -- gone: HEAD↔worktree is empty, so the row draws HEAD↔index instead, where the + -- change it still has is ---@param entry differ.FileEntry - ---@param diff differ.DiffModel -- the entry's built model; only its hunk count is read - ---@return differ.view.Staging|nil - local function stage_for(entry, diff) - if not stageable then + ---@return differ.DiffModel|nil + local function staged_model(entry) + if preview then return nil end - -- settle_pair is assigned below stage_for, so the lookup has to defer to call time - local function settle() - return settle_pair() - end - -- git records no rename, only the old path gone from the index and the new one - -- present, so a hunk patches an ordinary blob and the move rides along untouched. - -- the move is whole-file and belongs to the panel row's keys - local hunk_level = entry.status == "M" or entry.status == "R" or entry.status == "C" - if hunk_level and #diff.hunks > 0 then - return { - initial = entry.staged and "staged" or "unstaged", - apply = function(model, hunk, offset, reverse) - -- both directions patch the index, where the hunk sits at its - -- old-side start shifted past the hunks staged before it; `reverse` - -- only picks which way git reads the same patch - local p = - patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "old") - local ok, err = M.apply_patch(root, p, reverse) - if not ok then - local op = reverse and "unstage" or "stage" - notify(("hunk %s failed: %s"):format(op, err or ""), vim.log.levels.ERROR) - end - return ok - end, - revert = revert_hunk, - refresh = refresh_panel, - settle = settle, - } - end - -- shared by every whole-file status below. `git add` reads the file off disk, so - -- on a staged pair it also stages whatever the file's unstaged row holds - local function whole_file_apply(model, _, _, reverse) - if reverse then - return set_staged(root, entry, false) - end - local ok = set_staged(root, entry, true) - if ok and entry.staged and M.read(WORKTREE, root, entry.path) ~= model.new_text then - local msg = "%s is fully staged now, including the edits you had left unstaged" - notify(msg:format(entry.path), vim.log.levels.WARN) - end - return ok - end - -- a modification with no hunks: a mode change, a submodule pointer, a binary - -- file, a change git normalises away - if entry.status == "M" then - return { - initial = entry.staged and "staged" or "unstaged", - whole_file = true, - apply = whole_file_apply, - refresh = refresh_panel, - settle = settle, - } - end - if entry.status == "?" or entry.status == "A" then - return { - initial = entry.staged and "staged" or "unstaged", - whole_file = true, - apply = whole_file_apply, - -- `discard` drops the staged add before removing the file - revert = function() - return M.discard(root, entry) - end, - revert_label = "deletes the file", - refresh = refresh_panel, - settle = settle, - } - end - if entry.status == "D" then - return { - initial = entry.staged and "staged" or "unstaged", - whole_file = true, - apply = whole_file_apply, - revert = function() - return restore_deleted(entry) - end, - revert_label = "restores the file", - refresh = refresh_panel, - settle = settle, - } + local gone = entry.x == "A" and entry.y == "D" + if not (gone or staging_ops.partly_staged(entry)) then + return nil end - -- a rename or copy with no content to patch: the move is all there is - if entry.status == "R" or entry.status == "C" then - return { - initial = entry.staged and "staged" or "unstaged", - whole_file = true, - apply = whole_file_apply, - refresh = refresh_panel, - settle = settle, - } + local file = { + path = entry.path, + status = entry.status, + previous_path = entry.previous_path, + } + local model = M.model({ old = HEAD, new = INDEX }, root, file, head_branch(root)) + if #model.hunks == 0 then + return nil end - return nil + return model end -- (re)source the diff view from an entry's current git state. false when the @@ -1408,6 +1452,14 @@ function M.panel(opts) ---@return boolean shown local function show_entry(entry, source_opts) local model = model_for(entry) + local staged_only = false + if #model.hunks == 0 and not model.binary then + local staged = staged_model(entry) + if staged then + model = staged + staged_only = true + end + end if #model.hunks == 0 and not model.binary then -- a real change with no lines to show (a mode change, a rename, a final -- newline, an empty new file) opens on a notice, like a binary file does; @@ -1418,14 +1470,37 @@ function M.panel(opts) end model.notice = notice end - local staging = stage_for(entry, model) + if entry.status == "U" then + model.banner = "conflicted (:Differ mergetool)" + elseif entry.kept then + model.banner = "untracked copy on disk" + end + local staging ---@type differ.view.Staging|nil + if preview then + staging = staging_ops.for_preview(entry, model) + elseif staged_only then + staging = staging_ops.for_index(entry, model, false) + else + staging = staging_ops.for_whole(entry, model) + end + if staging and not preview then + if staging_ops.partly_staged(entry) then + staging.toggle_local = function() + show_local(entry) + end + else + staging.no_local = staging.no_local or no_local_reason(entry) + end + end if view and view:is_open() then view:set_source(model, staging, source_opts) else view = require("differ").diff_model(model, { staging = staging, can_stage = stageable, - on_edit_unstage = on_edit_unstage, + commit_preview = stageable and function() + set_preview(not preview) + end or nil, }) end active_entry = entry @@ -1436,13 +1511,13 @@ function M.panel(opts) return true end - -- the current changed-file entries for a path: a file can have a staged pair and - -- an unstaged pair, and an external op (lazygit) can empty the pair you were on + -- the current changed-file entries for a path: an external op (lazygit) can move it + -- to another section or out of the change set ---@param path string ---@return differ.FileEntry[] local function entries_for_path(path) local out = {} - local live = M.status_sections(root) + local live = preview and M.staged_sections(root) or M.status_sections(root) for _, sec in ipairs(live) do for _, e in ipairs(sec.entries) do if e.path == path then @@ -1453,87 +1528,22 @@ function M.panel(opts) return out end - -- after staging from an unstaged diff: when that took the file's last unstaged hunk - -- there's nothing left to review on this pair, so follow it to the staged one rather - -- than leave the diff on a pair git no longer has. only this direction follows: - -- unstaging the last staged hunk stays put, since that's what lets s re-stage it in - -- place, and swapping there would ping-pong the view on every u/s. returns whether - -- the view was re-targeted, so the caller knows its repaint is moot - ---@return boolean - settle_pair = function() - if not (panel and panel:is_alive() and view and view:is_open() and active_entry) then - return false - end - if active_entry.staged then - return false -- on the staged pair: u/s round-trips in place - end - local survivor ---@type differ.FileEntry|nil - for _, e in ipairs(entries_for_path(active_entry.path)) do - if not e.staged then - return false -- unstaged changes remain; stay frozen on them - end - survivor = survivor or e - end - if not survivor then - return false -- the file left the change set; revert's own path handles that - end - -- hold the position the stage happened at. this is a pair swap, not a file - -- switch, so landing on the first hunk would lose the reviewer's place; the - -- unstaged pair just emptied, so index and worktree agree and the line carries - local focus_line, focus_col = view:cursor_new_line() - if - not show_entry( - survivor, - { focus_line = focus_line, focus_col = focus_col, keep_folds = true } - ) - then - return false - end - panel:mark_selected(survivor) - return true - end - - -- edit-in-review on a staged diff (flow C): unstage the whole file so the - -- staged change returns to the worktree, then re-source the diff to the file's now- - -- unstaged view so the edit lands somewhere the diff reflects. driven explicitly by - -- the view (not the watcher, whose re-source is suppressed by the staging signature) - ---@param path string - on_edit_unstage = function(path) - M.unstage(root, path) - refresh_panel() -- the file moves to the Unstaged section; records last_sig - for _, e in ipairs(entries_for_path(path)) do - if not e.staged then - show_entry(e, { keep_folds = true }) -- switch the diff to index↔worktree - return - end - end - end - - -- re-source the view onto the shown file's current changes, preferring the pair it - -- was on, then the other pair, then the nearest surviving change. `outside` announces - -- a landing on a different change + -- re-source the view onto the shown file's current row, else the nearest surviving + -- change. `outside` announces a landing on a different change ---@param outside boolean ---@return boolean sourced - local function retarget_view(outside) + retarget_view = function(outside) if not (view and view:is_open() and active_entry) then return false end -- hold the cursor near where it was rather than snapping to the top local focus_line, focus_col = view:cursor_new_line() - local candidates = entries_for_path(active_entry.path) - local pick = candidates[1] - for _, e in ipairs(candidates) do - if e.staged == active_entry.staged then - pick = e - break - end - end + local pick = entries_for_path(active_entry.path)[1] local was = active_entry local source_opts = { focus_line = focus_line, focus_col = focus_col, keep_folds = true } if pick and show_entry(pick, source_opts) then - if outside and (pick.staged ~= was.staged or pick.status ~= was.status) then - local msg = "%s changed outside differ; showing its %s change instead" - notify(msg:format(pick.path, pick.staged and "staged" or "unstaged")) + if outside and pick.status ~= was.status then + notify(("%s changed outside differ"):format(pick.path)) end return true end @@ -1548,6 +1558,60 @@ function M.panel(opts) return false end + -- the refreshed list's row for `path`, or nil once it has left the change set + ---@param path string + ---@return differ.FileEntry|nil + local function listed(path) + for _, sec in ipairs(panel and panel.sections or {}) do + for _, e in ipairs(sec.entries) do + if e.path == path then + return e + end + end + end + return nil + end + + -- keep the open row's dw in step with the list: the first s can turn an Unstaged row + -- Partial, and the last a Partial row Staged, without the diff re-sourcing. the + -- LOCAL, INDEX and STAGED views keep the keys they opened with + local function sync_local() + local staging = view and view:is_open() and view.staging + if preview or not (staging and active_entry) or staging.badge then + return + end + local live = listed(active_entry.path) + if not live then + return + end + staging.toggle_local, staging.no_local = nil, no_local_reason(live) + if staging_ops.partly_staged(live) then + staging.toggle_local = function() + show_local(live) + end + end + end + + -- ds: flip the panel between every change and the commit preview, then reopen the + -- file on screen in the new listing, else the nearest one + set_preview = function(on) + if on then + local _, staged_total = nonempty_sections((M.staged_sections(root))) + if staged_total == 0 then + return notify("nothing staged to preview") + end + end + preview = on + local path = active_entry and active_entry.path + panel:refresh() + if not panel:is_alive() then + return + end + if not (path and panel:goto_path(path, true)) then + panel:open_nearest(true) + end + end + -- after an external git change (lazygit, a tmux-pane commit, `:!git`): refresh the -- list, then re-source the open diff so it reflects the new state too rather than -- staying frozen. gated on the signature so unrelated terminal events are no-ops @@ -1590,6 +1654,7 @@ function M.panel(opts) -- defaults, else Panel.new's own hardcoded fallbacks local cfg = require("differ").get_config() local panel_cfg = cfg.panel or {} + local panel_keys = cfg.keymaps.panel or require("differ.config").defaults.keymaps local return_tab, session_tab = open_session_tab() panel = Panel.new({ sections = nonempty, @@ -1597,7 +1662,10 @@ function M.panel(opts) footer = footer_label(args, root), actions = actions, on_external_change = refresh_external, - on_refresh = record_state, + on_refresh = function() + sync_local() + record_state() + end, -- the panel's keys move the pair the diff was built from, so the window follows. -- only for the file on screen: another row's diff keeps its frozen marks on_staged = function(paths) @@ -1606,6 +1674,48 @@ function M.panel(opts) end end, keymaps = cfg.keymaps.panel --[[@as differ.KeymapSet]], + review = stageable and review_hooks or nil, + extra_keymaps = stageable and { + { + spec = panel_keys.commit_preview, + fn = function() + set_preview(not preview) + end, + desc = "commit preview: staged changes only", + }, + -- the diff's own dw, driven from the row under the cursor: it opens the row + -- first where the view is showing another one + { + spec = panel_keys.toggle_local, + fn = function() + local entry = panel and panel:current_entry() + if not entry then + return + end + if preview then + return notify( + "the commit preview has no local view: ds goes back", + vim.log.levels.WARN + ) + end + if not staging_ops.partly_staged(entry) then + return notify(no_local_reason(entry), vim.log.levels.WARN) + end + local on_row = active_entry ~= nil and active_entry.path == entry.path + local local_view = view ~= nil + and view.staging ~= nil + and view.staging.badge == "LOCAL" + if on_row and local_view then + return retarget_view(false) -- the same key takes it back + end + if not on_row and not show_entry(entry) then + return + end + show_local(entry) + end, + desc = "local view: changes since staging", + }, + } or nil, listing = opts.listing or panel_cfg.listing, position = opts.position or panel_cfg.position, height = opts.height or panel_cfg.height, @@ -1632,6 +1742,10 @@ function M.panel(opts) -- empty sidebar next to a diff of a file that's now clean. only the worktree -- source can reach this; a rev-pair list never reloads on_empty = function() + if preview then + notify("nothing staged: back to every change") + return set_preview(false) + end notify("no changes left") local back = panel and panel.return_tab if panel then @@ -1654,11 +1768,9 @@ function M.panel(opts) panel.return_tab = return_tab if opts.open_first then -- land on the file (and line) :Differ was run from when it's in the change - -- set, else the first unstaged file (skipping the Staged section); leave the - -- cursor in the diff, not the panel. - -- a file changed on both pairs ("MM") takes its unstaged row: origin_line is a - -- worktree line, and index↔worktree is the only pair in that coordinate space - local on_origin = origin_rel and panel:focus_file(origin_rel, true) + -- set, else the first file with unstaged work (skipping the Staged section); + -- leave the cursor in the diff, not the panel + local on_origin = origin_rel and panel:focus_file(origin_rel) if not on_origin then panel:focus_first_unstaged() end @@ -1729,7 +1841,7 @@ function M.history(opts) local commits, err = M.log_commits(root, { path = relpath }) if #commits == 0 then if err then - return notify(chomp(err), vim.log.levels.ERROR) + return notify(exec.with_stderr("couldn't read history", err), vim.log.levels.ERROR) end return notify("no history for " .. relpath) end @@ -1816,7 +1928,7 @@ function M.range_history(opts) local commits, err = M.range_commits(root, range) if #commits == 0 then if err then - return notify(chomp(err), vim.log.levels.ERROR) + return notify(exec.with_stderr("couldn't read history", err), vim.log.levels.ERROR) end return notify("no commits in " .. range) end @@ -1913,4 +2025,9 @@ function M.close() notify("no differ view open") end +-- what git/staging.lua reads back: the pair models it re-marks from, a row's raw diff +-- line, and the index ref +M.raw_line, M.union_pairs, M.INDEX = raw_line, union_pairs, INDEX +M.live_row = live_row + return M diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua new file mode 100644 index 0000000..beaa647 --- /dev/null +++ b/lua/differ/git/staging.lua @@ -0,0 +1,1103 @@ +-- staging for one panel session's rows: the diff view's s/u/S/U/X, the commit preview's +-- frozen keys and the local view's. every op works out the content the index should +-- hold and writes it whole. the session passes in its root and its callbacks + +local rev = require("differ.git.rev") +local exec = require("differ.git.exec") +local index_ops = require("differ.git.index") +local patch = require("differ.git.patch") +local notify, git, git_ok = exec.notify, exec.git, exec.git_ok +local reload_buffer = exec.reload_buffer +local write_index, entry_paths = index_ops.write_index, index_ops.entry_paths +local set_staged, index_path = index_ops.set_staged, index_ops.index_path + +local M = {} + +-- the whole-file rows X acts on: a file that came, went, or changed kind altogether. +-- `T` is a path swapped for a symlink or the other way round, which has no hunk to take +---@type table +local REVERTABLE_WHOLE = { ["?"] = true, A = true, D = true, T = true } + +-- the `update-index --chmod` flag that gives a file a mode +---@type table +local CHMOD = { ["100644"] = "-x", ["100755"] = "+x" } + +local GITLINK = "160000" + +-- the confirms before s/S or u/U drop staged content nothing else holds, in the panel +-- and a file's own view +---@type differ.panel.DropPrompts +M.DROP_PROMPTS = { + stage = { + one = "Stage the deletion of %s? The staged file will be lost.", + many = "Stage these files? Staged changes in %s will be lost.", + }, + unstage = { + one = "Unstage %s? The staged change will be lost.", + many = "Unstage these files? Staged changes in %s will be lost.", + }, +} + +---@class differ.git.StagingCtx +---@field root string +---@field stageable boolean -- a worktree source: rev-pair rows don't stage +---@field refresh fun() -- reload the panel's list +---@field retarget fun(outside: boolean) -- re-source the view onto the row's state now +---@field preview_off fun() -- leave the commit preview +---@field current_view fun(): differ.View|nil + +---@param ctx differ.git.StagingCtx +---@return table +function M.new(ctx) + local gitmod = require("differ.git") + local root, stageable = ctx.root, ctx.stageable + local refresh_panel, retarget_view = ctx.refresh, ctx.retarget + + -- write `text` as `entry`'s staged content. an add left with nothing staged leaves + -- the index, and a worktree rename stages with its content; both move the row + ---@param entry differ.FileEntry + ---@param text string + ---@param mode? string -- the mode the entry takes; nil keeps the one it has + ---@return boolean ok + local function put_index(entry, text, mode) + if entry.x == "A" and text == "" then + if not gitmod.unstage(root, entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + return true + end + if not write_index(root, entry.path, text, mode) then + return false + end + if entry.y == "R" and entry.previous_path then + local drop = { "update-index", "--force-remove", "--", entry.previous_path } + if not git_ok(drop, root, "staging " .. entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + end + return true + end + + -- the mode of the side S (the worktree) or U (HEAD) takes, or nil when the index is + -- already there. a `diff --raw` line reads `: ...` + ---@param entry differ.FileEntry + ---@param staged boolean + ---@return string|nil + local function side_mode(entry, staged) + local line = gitmod.raw_line(root, staged and {} or { "--cached" }, entry) + if not line then + return nil + end + local index_mode, work_mode = rev.parse_raw_modes(line) + local want = staged and work_mode or index_mode + local held = staged and index_mode or work_mode + if not want or want == held or want:match("^0+$") then + return nil + end + return want + end + + -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, + -- then put back in the worktree + ---@param entry differ.FileEntry + ---@return boolean + local function mode_hidden(entry) + local cached = gitmod.raw_line(root, { "--cached" }, entry) + local unstaged = gitmod.raw_line(root, {}, entry) + if not (cached and unstaged) then + return false + end + local head_mode, index_mode = rev.parse_raw_modes(cached) + local _, work_mode = rev.parse_raw_modes(unstaged) + return index_mode ~= head_mode and index_mode ~= work_mode + end + + -- an op built on a failed index read would write the file's index from nothing + ---@param entry differ.FileEntry + ---@return false + local function index_unreadable(entry) + notify(("couldn't read %s from the index"):format(entry.path), vim.log.levels.WARN) + return false + end + + -- X on a hunk: the file gives those lines back, and `stage_index` moves the index's + -- half. the file is checked first, so one that changed those lines refuses before the + -- index moves. it patches rather than writes: the new side came through the clean + -- filter, and writing it back would push that conversion to disk + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param hunk differ.Hunk + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param stage_index fun(): boolean + ---@return boolean + local function revert_worktree(entry, model, hunk, offset, stage_index) + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") + if not gitmod.revert_patch(root, p, true) then + notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) + return false + end + if not stage_index() then + return false + end + local ok, err = gitmod.revert_patch(root, p) + reload_buffer(root, entry.path) + if not ok then + notify(exec.with_stderr("hunk revert failed", err), vim.log.levels.ERROR) + return false + end + return true + end + + -- staging for a row shown as HEAD↔worktree. staging a hunk gives the index the + -- worktree's version of its lines, unstaging keeps every staged change except those; + -- each op reads the pairs once and re-marks from the text it wrote + ---@param entry differ.FileEntry + ---@return differ.view.Staging + local function union_staging(entry) + local marks = require("differ.model.marks") + local stage = require("differ.model.stage") + local mode_change = false + if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then + mode_change = true -- the index holds a mode change, which no hunk shows + end + ---@type differ.view.Staging + local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } + + -- the view keeps staging.marks, so a re-mark writes through it. marks come from + -- the index's own lines at each hunk, and from the two pairs only when the index + -- changes a line between hunks + ---@param union differ.DiffModel|nil -- nil keeps the marks there are + ---@param cached differ.DiffModel|nil + ---@param unstaged differ.DiffModel|nil + local function remark(union, cached, unstaged) + if not (union and cached and unstaged) then + return + end + staging.hidden_in = nil + local blocks, ended = stage.index_blocks(union, cached.new_text) + local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] + if blocks then + held, hidden_in = marks.of_blocks(ended.hunks, blocks) + end + if held then + staging.marks.old, staging.marks.new = held.old, held.new + staging.hidden = nil + if mode_change then + staging.hidden = true + end + if #hidden_in > 0 then + staging.hidden = true + staging.hidden_in = hidden_in + end + return + end + local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) + staging.marks.old, staging.marks.new = fresh.old, fresh.new + local complete = marks.complete(union.hunks, cached.hunks, unstaged.hunks) + staging.hidden = nil + if mode_change then + staging.hidden = true + end + if not complete then + staging.hidden = true + local head = require("differ.util.text").to_lines(union.old_text) + staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) + end + end + + -- why `hunk` can't move on its own, either way naming the view where its change + -- is a hunk of its own and can be taken whole. `reaches` names another union + -- hunk a pair hunk would carry the op into; without one the two pairs line this + -- hunk's change up under a different one, leaving nothing here to write + ---@param take boolean + ---@param reaches integer|nil + ---@return string + local function refusal(take, reaches) + if not reaches then + if take then + return "this hunk can't be staged on its own here; stage it from dw" + end + return "this hunk can't be unstaged on its own here; unstage it from ds" + end + local msg = "this hunk can't be unstaged without hunk %d; unstage it from ds" + if take then + msg = "this hunk can't be staged without hunk %d; stage it from dw" + end + return msg:format(reaches) + end + + -- whether the diff still shows the file as it is. one drawn before the file or + -- HEAD moved would place an op by stale line numbers, so it refuses and re-reads + ---@param model differ.DiffModel -- the view's + ---@param union differ.DiffModel -- read fresh + ---@return boolean + local function drawn_current(model, union) + if model.old_text == union.old_text and model.new_text == union.new_text then + return true + end + notify("the file changed since the diff was drawn: re-reading", vim.log.levels.WARN) + vim.schedule(function() + refresh_panel() + retarget_view(false) + end) + return false + end + + remark(gitmod.union_models(root, entry)) + staging.apply = function(model, hunk, reverse) + if not hunk then + return false + end + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local take = not reverse + local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) + if not text then + -- neither refusal is a failure: the key moves on, and the message says + -- where this hunk's change can be taken whole + notify(refusal(take, reaches), vim.log.levels.WARN) + return false, true + end + if not put_index(entry, text) then + return false + end + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end + -- X on a hunk whose staged change reaches another can't take it alone, and a + -- drawing the file has moved on from places it by stale lines: both said before + -- the prompt, which says as much itself + staging.refuses_revert = function(model, idx) + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + index_unreadable(entry) + return true + end + if not drawn_current(model, union) then + return true + end + local _, reaches = stage.next_index(union, cached, unstaged, model.hunks[idx], false) + if reaches then + local msg = "this hunk can't be reverted without hunk %d" + notify(msg:format(reaches), vim.log.levels.WARN) + return true + end + return false + end + -- X throws the hunk away on both sides at once: the index gives up whatever of + -- it it holds and the worktree gives up the rest + staging.revert = function(model, idx) + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local hunk = model.hunks[idx] + local text, reaches = stage.next_index(union, cached, unstaged, hunk, false) + if reaches then + local msg = "this hunk can't be reverted without hunk %d" + notify(msg:format(reaches), vim.log.levels.WARN) + return false + end + -- a hunk the index holds nothing of on its own leaves the index where it is, + -- and the worktree still has the whole change to give back + local index_text = text or cached.new_text + local ok = revert_worktree(entry, model, hunk, 0, function() + return index_text == cached.new_text or put_index(entry, index_text) + end) + if not ok then + remark(gitmod.union_models(root, entry)) + return false + end + local work = require("differ.model.diff").revert_hunk(model, idx).new_text + remark(gitmod.union_pairs(root, entry.path, union.old_text, index_text, work)) + return true + end + -- S and U: the index takes the worktree's or HEAD's side of the file whole, + -- staged content no hunk shows and the mode included. the move a rename made + -- stays staged either way; the panel row's keys own that + staging.set_all = function(model, staged) + local union, cached = gitmod.union_models(root, entry) + if not (union and cached) then + return index_unreadable(entry), true + end + if not drawn_current(model, union) then + return false, true + end + local text = staged and union.new_text or union.old_text + local mode = side_mode(entry, staged) + if text == cached.new_text and not mode then + return false + end + if not put_index(entry, text, mode) then + return false, true + end + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end + -- the `!` hunks hold staged content neither side shows, and U takes all of it. a + -- hunk u can't move on its own drops nothing: its refusal needs no prompt first + staging.unheld = function(model, idx) + if not idx then + return staging.hidden == true + end + if not vim.tbl_contains(staging.hidden_in or {}, idx) then + return false + end + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + return true + end + return stage.next_index(union, cached, unstaged, model.hunks[idx], false) ~= nil + end + return staging + end + + -- X on a whole-file row, and what it does to the file. `letter` owns the change the + -- row shows: its status for a worktree row, the index's (x) in the commit preview. + -- a row X can't act on has no revert and says why instead, so the key refuses + -- before the prompt rather than after it + ---@param entry differ.FileEntry + ---@param letter string + ---@param preview boolean -- the entry is lettered by its index side + ---@return (fun(): boolean)|nil revert, string|nil label, string|nil no_revert + local function whole_file_revert(entry, letter, preview) + if letter == "D" then + if entry.kept then + local why = "X would overwrite %s on disk; u unstages the deletion instead" + return nil, nil, why:format(entry.path) + end + return function() + return index_ops.restore_deleted(root, entry) + end, + "restores the file" + end + local discard = function() + return gitmod.discard(root, entry, preview) + end + if letter == "A" or letter == "?" then + return discard, "deletes the file" + end + return discard, "resets it to HEAD" + end + + -- a whole-file row's staged state. a conflict holds both sides at once, which the + -- view has no third state for + ---@param entry differ.FileEntry + ---@return differ.model.HunkState + local function row_state(entry) + if entry.review == "staged" then + return "staged" + end + if entry.review == "partial" or entry.review == "conflict" then + return "partial" + end + return "unstaged" + end + + -- whether the index holds a blob for the row that neither HEAD nor the file on disk + -- has, so taking it out of the index loses it + ---@param entry differ.FileEntry + ---@return boolean + local function unheld_blob(entry) + local listed = git({ "ls-files", "-s", "--", index_path(entry) }, root) or "" + local sha = listed:match("^%d+ (%x+)") + if not sha then + return false + end + local head = git( + { "rev-parse", "-q", "--verify", "HEAD:" .. (entry.previous_path or entry.path) }, + root + ) + if head and vim.trim(head) == sha then + return false + end + local abs = root .. "/" .. entry.path + if vim.uv.fs_lstat(abs) then + local work = git({ "hash-object", "--path=" .. entry.path, abs }, root) + if work and vim.trim(work) == sha then + return false + end + end + return true + end + + -- whether staging the whole row loses staged content nothing else holds: a staged + -- change deleted on disk stages as the deletion, dropping the index's blob + ---@param entry differ.FileEntry + ---@return boolean + local function stage_drops(entry) + if entry.y ~= "D" or entry.x == " " or entry.x == "D" or entry.status == "U" then + return false + end + return unheld_blob(entry) + end + + -- whether the row stages by hunk once its diff has any. git records no rename, only + -- the old path gone from the index and the new one present, so a hunk stages an + -- ordinary blob and the move rides along untouched. the move is whole-file and + -- belongs to the panel row's keys + ---@param entry differ.FileEntry + ---@return boolean + local function by_line(entry) + local content = entry.status == "M" or entry.status == "R" or entry.status == "C" + local added = entry.status == "A" and entry.x == "A" + return (content or added) and entry.x ~= "D" and entry.y ~= "T" + end + + ---@param entry differ.FileEntry + ---@param diff differ.DiffModel -- the entry's built model; only its hunk count is read + ---@return differ.view.Staging|nil + local function for_whole(entry, diff) + if not stageable then + return nil + end + if by_line(entry) and #diff.hunks > 0 then + local staging = union_staging(entry) + if entry.x == "A" then + -- throwing away an add is deleting the file, not reverting a hunk of it + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, "A", false) + end + return staging + end + -- whole-file from here: nothing to stage by line (a mode change, a submodule, a + -- binary file, a bare rename, a file swapped for a symlink), or a file added, + -- untracked or deleted as one unit + ---@type differ.view.Staging + local staging = { + initial = row_state(entry), + whole_file = true, + apply = function(_, _, reverse) + if not set_staged(root, entry, not reverse) then + return false + end + -- the op moves the row's letters, which X and the next s or u act on + vim.schedule(function() + retarget_view(false) + end) + return true + end, + unheld = function() + return unheld_blob(entry) + end, + refresh = refresh_panel, + } + if stage_drops(entry) then + staging.confirm_stage = M.DROP_PROMPTS.stage.one + end + -- X puts a mode change or a binary file back as HEAD has it. a move stays with the + -- panel row's keys, and a submodule has no X + if entry.status == "M" then + local head_mode, work_mode = + rev.parse_raw_modes(gitmod.raw_line(root, { "HEAD" }, entry) or "") + if head_mode ~= GITLINK and work_mode ~= GITLINK then + staging.revert, staging.revert_label = whole_file_revert(entry, "M", false) + else + local why = "X can't move the checkout inside the submodule %s" + staging.no_revert = why:format(entry.path) + end + end + if entry.status == "M" or entry.status == "R" or entry.status == "C" then + if entry.status == "R" or entry.status == "C" then + staging.no_revert = "X on the panel row undoes the move" + end + return staging + end + -- an add's discard drops the staged entry before removing the file; a + -- typechange's puts HEAD's own kind of file back over the one on disk + if REVERTABLE_WHOLE[entry.status] then + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, entry.status, false) + return staging + end + return nil + end + + -- whether unstaging the whole row loses staged content nothing else holds: what U + -- in the row's diff view confirms on + ---@param entry differ.FileEntry + ---@param work_model fun(): differ.DiffModel -- HEAD↔worktree, built only when read + ---@return boolean + local function unstage_drops(entry, work_model) + if entry.x == " " or entry.x == "?" then + return false + end + if by_line(entry) and #work_model().hunks > 0 then + return union_staging(entry).hidden == true + end + return unheld_blob(entry) + end + + -- a row with a staged change and more on top of it: the one kind with a local view. + -- of the rows whose file is then deleted, only an add has one: the others stage the + -- deletion from their whole change, and an add's whole change is HEAD↔index + ---@param entry differ.FileEntry + ---@return boolean + local function partly_staged(entry) + if entry.status == "U" or entry.x == "?" then + return false + end + if entry.y == "D" and entry.x ~= "A" then + return false + end + return entry.x ~= " " and entry.y ~= " " + end + + -- staging frozen at the index the view opened on: s and u rebuild it from the model's + -- old side plus the hunks marked staged, so a marked hunk stays on screen. an index + -- written outside differ since then would be lost to that rebuild, so they refuse + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- read once, with at least one hunk + ---@param staged boolean -- every hunk's opening state + ---@param reopen fun() + ---@return differ.view.Staging + local function frozen_staging(entry, model, staged, reopen) + local splice = require("differ.model.apply").splice + local state = require("differ.model.marks").state + -- the commit preview opens staged with the index as its new side; the local view + -- opens unstaged with it as its old side + local held = staged and model.new_text or model.old_text + local at_index = index_path(entry) + local marks = { old = {}, new = {} } + ---@param h differ.Hunk + ---@param on boolean + local function mark(h, on) + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = on + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = on + end + end + for _, h in ipairs(model.hunks) do + mark(h, staged) + end + return { + marks = marks, + refresh = refresh_panel, + apply = function(_, hunk, reverse) + if not hunk then + return false + end + if (gitmod.read(gitmod.INDEX, root, at_index) or "") ~= held then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(reopen) + return false + end + mark(hunk, not reverse) + local applied = {} + for i, h in ipairs(model.hunks) do + applied[i] = state(marks, h) == "staged" + end + local text = splice(model, applied) + if put_index(entry, text) then + held, at_index = text, entry.path -- put_index stages a rename at the new path + return true + end + mark(hunk, reverse) + return false + end, + } + end + + -- whole-file staging frozen at the index the view opened on: u resets the row's + -- paths to HEAD, s puts back the entries the index held then. both refuse once the + -- index moves outside differ + ---@param entry differ.FileEntry + ---@param reopen fun() + ---@return differ.view.Staging + local function snapshot_staging(entry, reopen) + local paths = entry_paths(entry) + local function ls_files() + return git(vim.list_extend({ "ls-files", "-s", "-z", "--" }, paths), root) or "" + end + local listed = ls_files() + local held = {} ---@type table -- path -> update-index cacheinfo + for mode, sha, path in listed:gmatch("(%d+) (%x+) %d+\t([^%z]+)") do + held[path] = ("%s,%s,%s"):format(mode, sha, path) + end + ---@param reverse boolean + local function stage(reverse) + if reverse then + return set_staged(root, entry, false) + end + local ok = true + for _, path in ipairs(paths) do + local cmd = { "update-index", "--force-remove", "--", path } + if held[path] then + cmd = { "update-index", "--add", "--cacheinfo", held[path] } + end + ok = git_ok(cmd, root, "staging " .. path) and ok + end + return ok + end + return { + initial = "staged", + whole_file = true, + refresh = refresh_panel, + unheld = function() + return unheld_blob(entry) + end, + apply = function(_, _, reverse) + if ls_files() ~= listed then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(reopen) + return false + end + local ok = stage(reverse) + listed = ls_files() + return ok + end, + } + end + + -- X in a frozen view (the commit preview, the local view): the hunk leaves the index + -- if marked staged, and the file takes its old lines back, then `reopen` re-reads the + -- view. a last hunk leaves nothing to reopen, and the view hands over to the panel + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param staging differ.view.Staging + ---@param idx integer + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param reopen fun() + ---@param index_only? boolean -- the file already holds the hunk's old lines + ---@return boolean + local function revert_frozen(entry, model, staging, idx, offset, reopen, index_only) + local hunk = model.hunks[idx] + local function stage_index() + if require("differ.model.marks").state(staging.marks, hunk) ~= "staged" then + return true + end + if not staging.apply then + return false + end + return staging.apply(model, hunk, true) + end + local ok + if index_only then + ok = stage_index() + else + ok = revert_worktree(entry, model, hunk, offset, stage_index) + end + if not ok then + return false + end + if #model.hunks > 1 then + vim.schedule(function() + local current = ctx.current_view() + if current and current.staging == staging then + reopen() + end + end) + end + return true + end + + -- where the file stands on hunk `h` of a HEAD↔index model, from `drift`, the index's + -- lines against the file's: "put_back" when a drift hunk replaces exactly h's index + -- lines with its HEAD lines, "touched" when one overlaps them otherwise, nil when the + -- file leaves them alone. a zero-line side sits between two lines + ---@param drift differ.Hunk[] + ---@param m differ.DiffModel + ---@param h differ.Hunk + ---@param work string + ---@return "put_back"|"touched"|nil + local function placed_on(drift, m, h, work) + local to_lines = require("differ.util.text").to_lines + local function span(start, count) + if count == 0 then + return start + 0.5, start + 0.5 + end + return start, start + count - 1 + end + local lo, hi = span(h.new_start, h.new_count) + for _, w in ipairs(drift) do + local wlo, whi = span(w.old_start, w.old_count) + if w.old_start == h.new_start and w.old_count == h.new_count then + local head = + vim.list_slice(to_lines(m.old_text), h.old_start, h.old_start + h.old_count - 1) + local file = + vim.list_slice(to_lines(work), w.new_start, w.new_start + w.new_count - 1) + return vim.deep_equal(head, file) and "put_back" or "touched" + end + if wlo <= hi and lo <= whi then + return "touched" + end + end + return nil + end + + -- staging for a row drawn HEAD↔index, in the commit preview or where that is the + -- only diff the row has. an add or a deletion is one unit whose index entry comes + -- and goes, so it stages as a file + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@param preview boolean -- the row comes from the commit preview's listing + ---@return differ.view.Staging + local function for_index(entry, model, preview) + local staging + if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then + staging = frozen_staging(entry, model, true, function() + retarget_view(false) + end) + -- the file against the index this view opened on: s and u move the index + -- since, never the file + ---@param m differ.DiffModel + ---@return differ.Hunk[]|nil drift, string|nil work + local function drift_of(m) + local union = gitmod.union_models(root, entry) + if not union then + return nil + end + local drift = require("differ.model.diff").build({ + path = entry.path, + old_rev = "INDEX", + new_rev = "WORKTREE", + old_text = m.new_text, + new_text = union.new_text, + }).hunks + return drift, union.new_text + end + -- a staged hunk whose index lines the file doesn't hold goes with u + staging.unheld = function(m, idx) + local drift, work = drift_of(m) + if not (drift and work) then + return false + end + local state = require("differ.model.marks").state + for i, h in ipairs(m.hunks) do + local wanted = idx == nil or i == idx + if + wanted + and state(staging.marks, h) == "staged" + and placed_on(drift, m, h, work) + then + return true + end + end + return false + end + staging.revert = function(m, idx) + local h = m.hunks[idx] + local drift, work = drift_of(m) + if not (drift and work) then + return index_unreadable(entry) + end + local function reopen() + retarget_view(false) + end + local placed = placed_on(drift, m, h, work) + if placed == "put_back" then + return revert_frozen(entry, m, staging, idx, 0, reopen, true) + end + if placed == "touched" then + notify( + "the file has changed these lines: nothing reverted", + vim.log.levels.WARN + ) + return false + end + local at = h.new_count > 0 and h.new_start or h.new_start + 1 + local offset = require("differ.model.marks").shift(drift, at, "old") + return revert_frozen(entry, m, staging, idx, offset, reopen) + end + else + staging = snapshot_staging(entry, function() + retarget_view(false) + end) + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, entry.x, preview) + if entry.x == "A" and entry.y == "D" then + staging.revert_label = "deletes the staged file" + end + -- a whole-file discard takes the worktree too, and this view shows none of it. + -- read again at X: an edit since the view opened counts as well + local why = ("X would also discard the unstaged changes to %s"):format(entry.path) + local function unstaged_on_top(y) + return y ~= " " and y ~= "D" + end + local discard = staging.revert + if unstaged_on_top(entry.y) then + staging.revert, staging.revert_label, staging.no_revert = nil, nil, why + elseif discard then + staging.revert = function(...) + local live = gitmod.live_row(root, entry) + -- u here took the change out of the index: X has nothing staged to + -- discard, and s puts it back + if live and (live.x == " " or live.x == "?") then + notify(("%s has nothing staged now: s stages it back"):format(entry.path)) + return false + end + if live and unstaged_on_top(live.y) then + notify(why, vim.log.levels.WARN) + return false + end + return discard(...) + end + end + end + staging.badge = "INDEX" + return staging + end + + -- staging for a commit-preview row: the HEAD↔index keys, plus the way back out + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@return differ.view.Staging + local function for_preview(entry, model) + local staging = for_index(entry, model, true) + staging.badge = "STAGED" + staging.no_local = "the commit preview has no local view: ds goes back" + staging.leave = function() + ctx.preview_off() + end + return staging + end + + -- what u on `!` hunk `idx` of the local view writes: the index takes HEAD's lines + -- there, dropping the staged change the worktree undid. only the lines the hunk + -- rewrote go back where the staged block they sit in maps line for line; otherwise the + -- whole block does, and `scope` names the index lines it reaches. the hunk's lines are + -- the index's at open, moved by whatever s has staged since. no text, and why, when + -- the index can't be read or has changed outside differ + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- index↔worktree, as the view opened on it + ---@param staging differ.view.Staging + ---@param idx integer + ---@return string|nil text, string|nil scope, "unreadable"|"changed"|nil why + local function drop_index(entry, model, staging, idx) + local marks = require("differ.model.marks") + local stage = require("differ.model.stage") + local splice = require("differ.model.apply").splice + local applied, moved = {}, {} ---@type table, differ.Hunk[] + for i, h in ipairs(model.hunks) do + applied[i] = marks.state(staging.marks, h) == "staged" + if applied[i] then + moved[#moved + 1] = h + end + end + local _, cached = gitmod.union_models(root, entry) + if not cached then + return nil, nil, "unreadable" + end + if cached.new_text ~= splice(model, applied) then + return nil, nil, "changed" + end + local hunk = model.hunks[idx] + local placed = vim.tbl_extend("force", hunk, { + old_start = hunk.old_start + marks.shift(moved, hunk.old_start, "old"), + }) + local meets = stage.select_hunks(cached.hunks, "new", placed, "old", true) + local first, last = placed.old_start, placed.old_start + placed.old_count - 1 + local met, lo, hi = {}, math.huge, -math.huge ---@type integer[], number, number + for i, c in ipairs(cached.hunks) do + if meets[i] then + met[#met + 1] = i + if c.new_count > 0 then + lo, hi = math.min(lo, c.new_start), math.max(hi, c.new_start + c.new_count - 1) + end + end + end + local c = #met == 1 and cached.hunks[met[1]] or nil + if + c + and placed.old_count > 0 + and c.old_count == c.new_count + and first >= c.new_start + and last <= c.new_start + c.new_count - 1 + then + return stage.drop_lines(cached, met[1], first, last) + end + local keep = stage.select_hunks(cached.hunks, "new", placed, "old", false) + local text = splice(cached, keep) + if lo >= first and hi <= last then + return text + end + if lo == hi then + return text, ("line %d"):format(lo) + end + return text, ("lines %d-%d"):format(lo, hi) + end + + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param staging differ.view.Staging + ---@param idx integer + ---@param reopen fun() + ---@return boolean + local function drop_hidden(entry, model, staging, idx, reopen) + local text, _, why = drop_index(entry, model, staging, idx) + if why == "unreadable" then + return index_unreadable(entry) + end + if not text then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(function() + reopen() + end) + return false + end + if not put_index(entry, text) then + return false + end + refresh_panel() + reopen() + return true + end + + -- the index lines u on `!` hunk `idx` takes back when they reach past its own + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param staging differ.view.Staging + ---@param idx integer + ---@return string|nil + local function drop_scope(entry, model, staging, idx) + local _, scope = drop_index(entry, model, staging, idx) + return scope + end + + -- u in a whole-file local view: the index takes HEAD's side where it holds what the + -- worktree doesn't. an add goes, a binary takes HEAD's blob, and a mode HEAD's mode, + -- keeping the staged content. nil when the index holds nothing of the kind + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- index↔worktree + ---@return (fun(): boolean)|nil + local function whole_drop(entry, model) + if entry.x == "A" or model.binary then + return function() + return index_ops.unstage(root, entry.path) + end + end + local head_mode, index_mode = + rev.parse_raw_modes(gitmod.raw_line(root, { "--cached" }, entry) or "") + local flag = CHMOD[head_mode] + if not flag or head_mode == index_mode then + return nil + end + return function() + return git_ok({ "update-index", "--chmod=" .. flag, "--", entry.path }, root, "unstage") + end + end + + -- X in a whole-file local view: the worktree takes the index's side, its mode, its + -- blob, or a deleted file back as the index holds it + ---@param entry differ.FileEntry + ---@return boolean + local function whole_restore(entry) + if not git_ok({ "checkout", "--", entry.path }, root, "revert") then + return false + end + reload_buffer(root, entry.path) + return true + end + + -- the local view's staging, index↔worktree + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- index↔worktree + ---@param reopen fun() + ---@return differ.view.Staging + local function for_local(entry, model, reopen) + if #model.hunks > 0 and entry.y ~= "D" and entry.y ~= "T" then + local staging = frozen_staging(entry, model, false, reopen) + local _, cached = gitmod.union_models(root, entry) + -- an add has no HEAD lines for a rewritten line to go back to + if cached and entry.x ~= "A" then + staging.hidden_in = + require("differ.model.marks").restaged(model.hunks, cached.hunks) + end + staging.unstage_hidden = function(idx) + return drop_hidden(entry, model, staging, idx, reopen) + end + staging.drop_scope = function(idx) + return drop_scope(entry, model, staging, idx) + end + staging.revert = function(m, idx) + return revert_frozen(entry, m, staging, idx, 0, reopen) + end + -- staging the last hunk empties "changes since staging", so there's nothing + -- left for this view to show and it hands back to the whole change + local stage_hunk = staging.apply + staging.apply = function(m, hunk, reverse) + if not (stage_hunk and stage_hunk(m, hunk, reverse)) then + return false + end + local state = require("differ.model.marks").state + for _, h in ipairs(model.hunks) do + if state(staging.marks, h) ~= "staged" then + return true + end + end + vim.schedule(function() + retarget_view(false) + end) + return true + end + return staging + end + -- s stages the file whole: what's left has no lines to take, or is the file's + -- deletion, which a hunk would stage as an empty file. that leaves this view + -- nothing to show. u has no reverse here: unstaging the row would take its + -- staged content with it + ---@type differ.view.Staging + local staging = { + initial = "unstaged", + whole_file = true, + refresh = refresh_panel, + apply = function(_, _, reverse) + if reverse or not set_staged(root, entry, true) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + return true + end, + } + if stage_drops(entry) then + staging.confirm_stage = M.DROP_PROMPTS.stage.one + end + staging.revert = function() + return whole_restore(entry) + end + staging.revert_label = "resets it to the index" + local drop = whole_drop(entry, model) + if drop then + staging.hidden_in = { 1 } + staging.unstage_hidden = function() + if not drop() then + return false + end + refresh_panel() + retarget_view(false) + return true + end + end + return staging + end + + return { + for_whole = for_whole, + for_local = for_local, + for_preview = for_preview, + for_index = for_index, + partly_staged = partly_staged, + stage_drops = stage_drops, + unstage_drops = unstage_drops, + } +end + +return M diff --git a/lua/differ/init.lua b/lua/differ/init.lua index 342e3f7..ed03a68 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -80,7 +80,7 @@ end ---@field context? number|"full" ---@field staging? differ.view.Staging ---@field can_stage? boolean ----@field on_edit_unstage? fun(path: string) +---@field commit_preview? fun() ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -104,7 +104,7 @@ function M.diff_model(model, opts) keymaps = cfg.keymaps.diff --[[@as table]], staging = opts.staging, can_stage = opts.can_stage, - on_edit_unstage = opts.on_edit_unstage, + commit_preview = opts.commit_preview, extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, on_cursor = opts.on_cursor, @@ -218,7 +218,7 @@ end function M.jump_to_file() local view = M.active_view() if not view then - return vim.notify("differ: no diff view here", vim.log.levels.WARN) + return vim.notify("differ: no differ view open", vim.log.levels.WARN) end view:jump_to_file() end @@ -230,7 +230,7 @@ end function M.edit_file() local view = M.active_view() if not view then - return vim.notify("differ: no diff view here", vim.log.levels.WARN) + return vim.notify("differ: no differ view open", vim.log.levels.WARN) end view:edit_file() end @@ -243,7 +243,7 @@ end function M.goto_hunk(direction, opts) local view = M.active_view() if not view then - return vim.notify("differ: no diff view here", vim.log.levels.WARN) + return vim.notify("differ: no differ view open", vim.log.levels.WARN) end view:goto_hunk(direction, opts) end diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index d6a833a..200d208 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -604,7 +604,7 @@ local function resolve_choice(choice) local cur = vim.api.nvim_win_get_cursor(session.result_win)[1] local region = region_at(regions, cur) if not region then - return notify("no conflict under the cursor") + return notify("no conflict under the cursor", vim.log.levels.WARN) end -- the live buffer parse carries no base under the default conflictStyle, so take-base -- reads the slab recovered at build time, mapped live index -> original via `order` @@ -612,8 +612,7 @@ local function resolve_choice(choice) local original = original_index(region.result_start) if not original then return notify( - "this conflict's markers were edited, so its ancestor can't be identified; " - .. "run :Differ mergetool again to re-read them", + "this conflict's markers were edited; run :Differ mergetool again to use its base", vim.log.levels.WARN ) end @@ -649,7 +648,7 @@ local function resolve_choice(choice) vim.api.nvim_win_set_cursor(session.result_win, { target.result_start, 0 }) on_cursor_moved() elseif #session.regions == 0 then - notify("all conflicts resolved — :w to save and stage") + notify("all conflicts resolved: :w to save and stage") end end @@ -1021,9 +1020,8 @@ function lay_out(root, relpath, model, layout) if not session.autoformat_warned then session.autoformat_warned = true notify( - "conflict markers were reformatted on save and the file was NOT staged. " - .. "your format_on_save isn't honouring vim.b.disable_autoformat, " - .. "which differ sets on this buffer", + "a formatter changed the conflict markers on save; not staged. " + .. "your format_on_save ignores vim.b.disable_autoformat", vim.log.levels.WARN ) end @@ -1041,7 +1039,11 @@ function lay_out(root, relpath, model, layout) end end) else - notify(("%d conflict(s) still unresolved — not staged"):format(#session.regions)) + local noun = "conflicts" + if #session.regions == 1 then + noun = "conflict" + end + notify(("%d %s still unresolved; not staged"):format(#session.regions, noun)) end end, }) diff --git a/lua/differ/merge/model.lua b/lua/differ/merge/model.lua index 4c929ad..0b91dd2 100644 --- a/lua/differ/merge/model.lua +++ b/lua/differ/merge/model.lua @@ -160,11 +160,11 @@ function M.build(root, relpath, head) local git = require("differ.git") local result_text = git.read({ kind = "worktree", label = "WORKTREE" }, root, relpath) if not result_text then - return nil, "file is not in the working tree" + return nil, relpath .. " is not in the working tree" end local regions = conflict.parse(to_lines(result_text)) if #regions == 0 then - return nil, "no conflicts to resolve" + return nil, relpath .. " has no conflicts to resolve" end local ours_text = git.read_stage(root, relpath, 2) local base_text = git.read_stage(root, relpath, 1) diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua new file mode 100644 index 0000000..dac38e3 --- /dev/null +++ b/lua/differ/model/apply.lua @@ -0,0 +1,106 @@ +-- the text a diff's two sides splice into: each hunk takes its new block when applied +-- and its old block otherwise, with the unchanged lines between read from one side. +-- staging writes the result whole, so it only ever applies whole hunks; pure lua, no vim API + +local text = require("differ.util.text") +local to_lines = text.to_lines + +local M = {} + +---@param model differ.DiffModel +---@param applied table -- hunk index -> take its new block +---@param base? "old"|"new" -- the side the unchanged lines and their ending come from; default "old" +---@return string +function M.splice(model, applied, base) + base = base or "old" + local lines = to_lines(base == "new" and model.new_text or model.old_text) + local out, next_line, tail = {}, 1, base + for i, h in ipairs(model.hunks) do + local start, count = h[base .. "_start"], h[base .. "_count"] + -- a hunk with no lines on `base` sits after the line `start` names + local first = count > 0 and start or start + 1 + for l = next_line, first - 1 do + out[#out + 1] = lines[l] + end + local side = applied[i] and "new" or "old" + for _, line in ipairs(h[side .. "_lines"]) do + out[#out + 1] = line + end + next_line = first + count + if next_line > #lines then + tail = side -- nothing follows the hunk, so the file ends as its side does + end + end + for l = next_line, #lines do + out[#out + 1] = lines[l] + end + if #out == 0 then + return "" + end + local ending = tail == "new" and model.new_text or model.old_text + return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") +end + +---@param lines string[] +---@param start integer +---@param count integer +---@return string[] +local function slice(lines, start, count) + local out = {} + for l = start, start + count - 1 do + out[#out + 1] = lines[l] + end + return out +end + +-- `model` with its hunks' lines as text.ended_lines reads each side, so a hunk reaching +-- an unterminated end holds that side's last line with NO_EOL on it +---@param model differ.DiffModel +---@return differ.DiffModel +function M.ended(model) + local old, new = text.ended_lines(model.old_text), text.ended_lines(model.new_text) + local hunks = {} + for i, h in ipairs(model.hunks) do + hunks[i] = { + old_start = h.old_start, + old_count = h.old_count, + old_lines = slice(old, h.old_start, h.old_count), + new_start = h.new_start, + new_count = h.new_count, + new_lines = slice(new, h.new_start, h.new_count), + } + end + local out = {} + for k, v in pairs(model) do + out[k] = v + end + out.hunks = hunks + return out +end + +-- the model's unchanged old lines with `blocks` at its hunks. every line is an ended +-- line (M.ended's side lines, or marks.blocks over ended lines), so the file ends +-- without a newline exactly when its last line carries NO_EOL +---@param model differ.DiffModel +---@param blocks string[][] -- hunk index -> the lines at that hunk +---@return string +function M.join(model, blocks) + local lines = text.ended_lines(model.old_text) + local out, next_line = {}, 1 + for i, h in ipairs(model.hunks) do + local first = h.old_count > 0 and h.old_start or h.old_start + 1 + for l = next_line, first - 1 do + out[#out + 1] = lines[l] + end + for _, line in ipairs(blocks[i]) do + out[#out + 1] = line + end + next_line = first + h.old_count + end + for l = next_line, #lines do + out[#out + 1] = lines[l] + end + return text.ended_text(out) +end + +return M diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index f1d3c6a..5a77289 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -7,7 +7,6 @@ ---@field new_count integer ---@field old_lines string[] ---@field new_lines string[] ----@field pairs differ.LinePair[]|nil ---@class differ.DiffModel ---@field path string @@ -20,10 +19,10 @@ ---@field root string|nil -- repo root (absolute), so jump-to-file can resolve the real file (set by the frontend) ---@field binary boolean|nil -- either side is binary: no hunks, renderers show a placeholder ---@field notice string|nil -- why a zero-hunk diff has nothing to show; rendered in place of the diff +---@field banner string|nil -- a note the winbar carries over the diff local text_util = require("differ.util.text") local to_lines = text_util.to_lines -local ensure_trailing_nl = text_util.ensure_trailing_nl local is_binary = text_util.is_binary local M = {} @@ -75,11 +74,11 @@ function M.build(opts) local old_lines = to_lines(opts.old_text) local new_lines = to_lines(opts.new_text) - local raw = - vim.text.diff(ensure_trailing_nl(opts.old_text), ensure_trailing_nl(opts.new_text), { - result_type = "indices", - algorithm = "histogram", - }) + -- a last line with and without its newline differ, as in git + local raw = vim.text.diff(opts.old_text, opts.new_text, { + result_type = "indices", + algorithm = "histogram", + }) ---@cast raw integer[][] local hunks = {} @@ -107,54 +106,17 @@ function M.build(opts) } end --- why a zero-hunk model has nothing to show, from its two texts alone. build --- ensures a trailing newline on both sides, so that's the only way two differing --- texts reach zero hunks. nil when only the frontend knows (a mode change, a rename) +-- why a zero-hunk model has nothing to show, from its two texts alone. nil when only +-- the frontend knows (a mode change, a rename) ---@param model differ.DiffModel ----@return "empty"|"eol_added"|"eol_removed"|"identical"|nil +---@return "empty"|"identical"|nil function M.empty_reason(model) - if #model.hunks > 0 or model.binary then + if #model.hunks > 0 or model.binary or model.old_text ~= model.new_text then return nil end - if model.old_text ~= model.new_text then - return model.new_text:sub(-1) == "\n" and "eol_added" or "eol_removed" - end return model.old_text == "" and "empty" or "identical" end --- the new side's text with hunk `idx` undone: its old lines put back where its new --- lines sit. only the new side ever moves, in both diff directions (a revert rewrites --- the worktree on an index↔worktree diff, the index on a head↔index one), so there's --- one case here rather than two ----@param model differ.DiffModel ----@param h differ.Hunk ----@return string -local function reverted_text(model, h) - local lines = to_lines(model.new_text) - -- a zero-count hunk occupies nothing on the new side, and `new_start` names the - -- line it sits *after*, so the restored lines go in after it rather than over it - local at = h.new_count > 0 and h.new_start or h.new_start + 1 - local out = {} - for i = 1, at - 1 do - out[#out + 1] = lines[i] - end - vim.list_extend(out, h.old_lines) - for i = at + h.new_count, #lines do - out[#out + 1] = lines[i] - end - - -- whichever side supplies the result's last line supplies its terminator too: - -- a hunk reaching the end of the new side leaves no tail behind it, so the old - -- side's ending wins. getting this wrong would re-diff as a phantom eof hunk - local last = h.new_count > 0 and (h.new_start + h.new_count - 1) or h.new_start - local source = last == #lines and model.old_text or model.new_text - local text = table.concat(out, "\n") - if #out > 0 and source:sub(-1) == "\n" then - text = text .. "\n" - end - return text -end - -- a model with hunk `idx` reverted on the new side. rebuilt from the spliced text -- rather than patched in place, so the hunk list can never drift from the text it -- describes; callers hold the result as their new frozen model. the rebuild @@ -164,13 +126,19 @@ end ---@param idx integer ---@return differ.DiffModel function M.revert_hunk(model, idx) - local h = assert(model.hunks[idx], "revert_hunk: no hunk at index " .. tostring(idx)) + assert(model.hunks[idx], "revert_hunk: no hunk at index " .. tostring(idx)) + -- only the new side moves: the worktree on an index↔worktree diff, the index on a + -- HEAD↔index one + local applied = {} + for i in ipairs(model.hunks) do + applied[i] = i ~= idx + end return M.build({ path = model.path, old_rev = model.old_rev, new_rev = model.new_rev, old_text = model.old_text, - new_text = reverted_text(model, h), + new_text = require("differ.model.apply").splice(model, applied, "new"), head = model.head, root = model.root, }) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua new file mode 100644 index 0000000..bf9075e --- /dev/null +++ b/lua/differ/model/marks.lua @@ -0,0 +1,626 @@ +-- which lines of a HEAD↔worktree diff the index already holds. +-- each real pair shares one side with the union (HEAD↔index shares HEAD, index↔worktree +-- shares the worktree), so a displayed line is classified by whichever pair shares its +-- side and the two never have to be composed. pure lua, no nvim API + +local M = {} + +---@alias differ.model.HunkState "staged"|"unstaged"|"partial" + +---@class differ.model.Marks +---@field old table -- old lnum -> the index already dropped this line +---@field new table -- new lnum -> the index already holds this line +---@field whole differ.model.HunkState|nil -- a whole-file source: the file's state as one unit + +-- the lines a hunk list covers on one side, as a set +---@param hunks differ.Hunk[] +---@param side "old"|"new" +---@return table +local function covered(hunks, side) + local out = {} + local start_key, count_key = side .. "_start", side .. "_count" + for _, h in ipairs(hunks) do + for l = h[start_key], h[start_key] + h[count_key] - 1 do + out[l] = true + end + end + return out +end + +-- classify every line of a HEAD↔worktree diff as staged or not. a HEAD line the union +-- deletes is staged when HEAD↔index deletes it too (the index has already dropped it); +-- a worktree line the union adds is staged unless index↔worktree adds it (anything +-- that pair leaves as context is in the index already) +---@param union differ.Hunk[] -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@param unstaged differ.Hunk[] -- index↔worktree +---@return differ.model.Marks +function M.classify(union, cached, unstaged) + local dropped = covered(cached, "old") + local fresh = covered(unstaged, "new") + local marks = { old = {}, new = {} } + for _, h in ipairs(union) do + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = dropped[l] or false + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = not fresh[l] + end + end + return marks +end + +-- a hunk's state, rolled up from its lines: staged when the index holds all of it, +-- unstaged when it holds none, partial in between +---@param marks differ.model.Marks +---@param h differ.Hunk +---@return differ.model.HunkState +function M.state(marks, h) + local staged, live = 0, 0 + for l = h.old_start, h.old_start + h.old_count - 1 do + if marks.old[l] then + staged = staged + 1 + else + live = live + 1 + end + end + for l = h.new_start, h.new_start + h.new_count - 1 do + if marks.new[l] then + staged = staged + 1 + else + live = live + 1 + end + end + if live == 0 then + return "staged" + end + if staged == 0 then + return "unstaged" + end + return "partial" +end + +-- the HEAD line a union hunk's lines start at, and the first HEAD line after them +---@param h differ.Hunk +---@return integer first, integer after +local function old_bounds(h) + local first = h.old_count > 0 and h.old_start or h.old_start + 1 + return first, first + h.old_count +end + +-- whether the index holds `lines` from line `at` +---@param index string[] +---@param at integer +---@param lines string[] +---@return boolean +local function holds(index, at, lines) + for k, line in ipairs(lines) do + if index[at + k - 1] ~= line then + return false + end + end + return true +end + +-- the block lengths worth trying for hunk `h` at index line `at`. `next_line` is the +-- HEAD line the block has to be followed by (nil at the end of HEAD), and `where` maps +-- each index line to the lines it sits at, ascending +---@alias differ.model.BlockLengths +---| fun(h: differ.Hunk, index: string[], at: integer, next_line?: string, where: table): integer[] + +-- lengths for a block that is exactly one of the hunk's sides +---@type differ.model.BlockLengths +local function whole_lengths(h, index, at) + local out = {} + if holds(index, at, h.new_lines) then + out[#out + 1] = h.new_count + end + if holds(index, at, h.old_lines) and h.old_count ~= out[1] then + out[#out + 1] = h.old_count + end + return out +end + +-- every length that ends where `next_line` sits, shortest first, or the rest of the +-- index when nothing follows +---@type differ.model.BlockLengths +local function any_lengths(_, index, at, next_line, where) + if next_line == nil then + return { #index - at + 1 } + end + local lines = where[next_line] or {} + local lo, hi = 1, #lines + 1 -- the first entry at or after `at` + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if lines[mid] < at then + lo = mid + 1 + else + hi = mid + end + end + local out = {} + for k = lo, #lines do + out[#out + 1] = lines[k] - at + end + return out +end + +-- lengths tried and lines compared past which a cut is reported as ambiguous. a file of +-- mostly repeated lines leaves any_lengths a length to try at nearly every line +local MAX_STEPS = 1e6 + +-- the ways to cut the index into a block per union hunk, with HEAD's unchanged lines +-- between them: the cut when there is only one, and how many there are (0, 1, or 2 for +-- two or more, or for a search past MAX_STEPS) +---@param union differ.Hunk[] +---@param head string[] +---@param index string[] +---@param lengths differ.model.BlockLengths +---@return string[][] cut, integer ways +local function cut(union, head, index, lengths) + local ways = {} ---@type table -- "hunk:index line" -> ways through + local picks = {} ---@type table -- "hunk:index line" -> first block's {at, n} + local where = {} ---@type table + for l, line in ipairs(index) do + where[line] = where[line] or {} + table.insert(where[line], l) + end + local steps = 0 + ---@param i integer -- the next union hunk + ---@param from integer -- the next HEAD line + ---@param at integer -- the next index line + ---@return integer + local function count(i, from, at) + local key = i .. ":" .. at + if ways[key] then + return ways[key] + end + local h = union[i] + local first, after = #head + 1, #head + 1 + if h then + first, after = old_bounds(h) + end + for l = from, first - 1 do + steps = steps + 1 + if index[at] ~= head[l] then + ways[key] = 0 + return 0 + end + at = at + 1 + end + if not h then + ways[key] = 0 + if at == #index + 1 then + ways[key] = 1 + end + return ways[key] + end + local total = 0 + local tried = lengths(h, index, at, head[after], where) + steps = steps + #tried + if steps > MAX_STEPS then + return 2 + end + for _, n in ipairs(tried) do + -- the unchanged line after the block, when there is one, has to come next + local fits = after > #head or index[at + n] == head[after] + local through = 0 + if fits then + through = count(i + 1, after, at + n) + end + if through > 0 and total == 0 then + picks[key] = { at, n } + end + total = total + through + if total >= 2 then + break + end + end + ways[key] = math.min(total, 2) + return ways[key] + end + local total = count(1, 1, 1) + local out, at = {}, 1 + if total ~= 1 then + return out, total -- a cut that isn't the only one is never read + end + for i in ipairs(union) do + local pick = picks[i .. ":" .. at] + local block = {} + for l = pick[1], pick[1] + pick[2] - 1 do + block[#block + 1] = index[l] + end + out[i] = block + at = pick[1] + pick[2] + end + return out, total +end + +-- the index's lines at each union hunk, when the index holds every unchanged line +-- between them. a cut where every block is one of its hunk's sides wins; nil when the +-- index can't be cut, or can be cut more than one way +---@param union differ.Hunk[] -- HEAD↔worktree +---@param head string[] -- HEAD's lines +---@param index string[] -- the index's lines +---@return string[][]|nil +function M.blocks(union, head, index) + for _, lengths in ipairs({ whole_lengths, any_lengths }) do + local found, ways = cut(union, head, index, lengths) + if ways == 1 then + return found + end + if ways > 1 then + return nil + end + end + return nil +end + +-- LCS grid size past which a hunk's block isn't compared line by line +local MAX_CELLS = 1e6 + +-- which lines of `a` and `b` a longest common subsequence pairs up; nil past MAX_CELLS +---@param a string[] +---@param b string[] +---@return table|nil in_a, table|nil in_b +local function common(a, b) + local n, m = #a, #b + if n * m > MAX_CELLS then + return nil, nil + end + local dp = {} -- dp[i][j] = LCS length of a[i..] and b[j..] + for i = 1, n + 1 do + dp[i] = { [m + 1] = 0 } + end + for j = 1, m + 1 do + dp[n + 1][j] = 0 + end + for i = n, 1, -1 do + for j = m, 1, -1 do + if a[i] == b[j] then + dp[i][j] = dp[i + 1][j + 1] + 1 + else + dp[i][j] = math.max(dp[i + 1][j], dp[i][j + 1]) + end + end + end + local in_a, in_b = {}, {} + local i, j = 1, 1 + while i <= n and j <= m do + if a[i] == b[j] then + in_a[i], in_b[j] = true, true + i, j = i + 1, j + 1 + elseif dp[i + 1][j] >= dp[i][j + 1] then + i = i + 1 + else + j = j + 1 + end + end + return in_a, in_b +end + +-- mark one hunk's lines from its index block. a block line is HEAD's where it pairs +-- with an old line, else the worktree's where it pairs with a new one +---@param h differ.Hunk +---@param block string[] +---@param marks differ.model.Marks +---@return boolean compared -- false for a hunk too big to compare +---@return boolean hidden -- a block line neither side has +local function mark_block(h, block, marks) + -- a block that is one whole side is that side, even where the two sides share a line + for _, staged in ipairs({ true, false }) do + local side = staged and h.new_lines or h.old_lines + if #block == #side and holds(block, 1, side) then + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = staged + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = staged + end + return true, false + end + end + local kept, used = common(h.old_lines, block) + if not (kept and used) then + return false, false + end + local rest = {} + for k, line in ipairs(block) do + if not used[k] then + rest[#rest + 1] = line + end + end + local held, placed = common(h.new_lines, rest) + if not (held and placed) then + return false, false + end + for k = 1, h.old_count do + marks.old[h.old_start + k - 1] = not kept[k] + end + local paired = 0 + for k = 1, h.new_count do + marks.new[h.new_start + k - 1] = held[k] == true + if held[k] then + paired = paired + 1 + end + end + return true, paired < #rest +end + +-- marks for the union hunks from the index's block at each, and the hunks whose block +-- holds a line neither side has. nil when a hunk is too big to compare +---@param union differ.Hunk[] +---@param blocks string[][] +---@return differ.model.Marks|nil marks, integer[] hidden +function M.of_blocks(union, blocks) + local marks = { old = {}, new = {} } + local hidden = {} + for i, h in ipairs(union) do + local compared, extra = mark_block(h, blocks[i], marks) + if not compared then + return nil, {} + end + if extra then + hidden[#hidden + 1] = i + end + end + return marks, hidden +end + +-- a hunk's real lines on one side, [start, stop). a zero-count hunk has none +---@param h differ.Hunk +---@param side "old"|"new" +---@return integer start, integer stop +local function span(h, side) + local at, n = h[side .. "_start"], h[side .. "_count"] + return at, at + n +end + +-- whether two hunks meet on the sides given: lines in common, or an insertion that +-- sits inside the other's lines or at either edge of them. an insertion's start names +-- the line it follows +---@param a differ.Hunk +---@param a_side "old"|"new" +---@param b differ.Hunk +---@param b_side "old"|"new" +---@return boolean +function M.meets(a, a_side, b, b_side) + local as, an = a[a_side .. "_start"], a[a_side .. "_count"] + local bs, bn = b[b_side .. "_start"], b[b_side .. "_count"] + if an > 0 and bn > 0 then + return as < bs + bn and bs < as + an + end + if an == 0 and bn == 0 then + return as == bs + end + if an == 0 then + return as >= bs - 1 and as <= bs + bn - 1 + end + return bs >= as - 1 and bs <= as + an - 1 +end + +-- another union hunk that a pair hunk meeting `anchor` also meets, or nil. staging +-- takes pair hunks whole, so one of these would carry the op into that hunk too. +-- union hunks never meet each other, so the one `anchor` meets is itself +---@param union differ.Hunk[] +---@param anchor differ.Hunk +---@param pair differ.Hunk[] +---@param side "old"|"new" -- the side union and pair share +---@return integer|nil +function M.shared_with(union, anchor, pair, side) + for _, p in ipairs(pair) do + if M.meets(p, side, anchor, side) then + for j, u in ipairs(union) do + if M.meets(p, side, u, side) and not M.meets(u, side, anchor, side) then + return j + end + end + end + end + return nil +end + +-- the HEAD↔index hunks that touch a HEAD↔worktree hunk +---@param h differ.Hunk -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@return differ.Hunk[] +local function touching(h, cached) + local out = {} + for _, c in ipairs(cached) do + if M.meets(h, "old", c, "old") then + out[#out + 1] = c + end + end + return out +end + +---@param lines string[] +---@return string +local function bag(lines) + table.sort(lines) + return table.concat(lines, "\n") +end + +-- the HEAD↔worktree hunks whose lines, read as the marks say, don't hold what the index +-- does there. by content, so a hunk whose index lines differ only in order isn't counted +---@param head string[] -- HEAD's lines +---@param union differ.Hunk[] -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@param marks differ.model.Marks +---@return integer[] +function M.hidden_in(head, union, cached, marks) + local out = {} + for i, h in ipairs(union) do + local in_union, in_cached, region = {}, {}, {} + local ua, ub = span(h, "old") + for l = ua, ub - 1 do + in_union[l], region[l] = true, true + end + local actual = {} + for _, c in ipairs(touching(h, cached)) do + local ca, cb = span(c, "old") + for l = ca, cb - 1 do + in_cached[l], region[l] = true, true + end + for _, line in ipairs(c.new_lines or {}) do + actual[#actual + 1] = line + end + end + local implied = {} + for l in pairs(region) do + if not in_cached[l] then + actual[#actual + 1] = head[l] + end + if not in_union[l] or not marks.old[l] then + implied[#implied + 1] = head[l] + end + end + local na = h.new_start + for k, line in ipairs(h.new_lines or {}) do + if marks.new[na + k - 1] then + implied[#implied + 1] = line + end + end + if bag(actual) ~= bag(implied) then + out[#out + 1] = i + end + end + return out +end + +-- whether index↔worktree hunk `h` puts back lines HEAD↔index hunk `c` deleted outright +---@param h differ.Hunk -- index↔worktree +---@param c differ.Hunk -- HEAD↔index +---@return boolean +local function puts_back(h, c) + if not (h.old_count == 0 and c.new_count == 0 and h.old_start == c.new_start) then + return false + end + return table.concat(h.new_lines or {}, "\n") == table.concat(c.old_lines or {}, "\n") +end + +-- the index↔worktree hunks that change staged content again: one that rewrites or drops +-- a line the index added, or puts back lines the index deleted. what they undo is +-- staged content the HEAD↔worktree diff can't show +---@param unstaged differ.Hunk[] -- index↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@return integer[] +function M.restaged(unstaged, cached) + local added = covered(cached, "new") + local out = {} + for i, h in ipairs(unstaged) do + local hit = false + for l = h.old_start, h.old_start + h.old_count - 1 do + hit = hit or added[l] == true + end + for _, c in ipairs(cached) do + hit = hit or puts_back(h, c) + end + if hit then + out[#out + 1] = i + end + end + return out +end + +-- how far line `lnum` on side `from` moves on the other side: the net lines the hunks +-- ending before it add there +---@param hunks differ.Hunk[] +---@param lnum integer +---@param from "old"|"new" +---@return integer +function M.shift(hunks, lnum, from) + local to = from == "old" and "new" or "old" + local out = 0 + for _, h in ipairs(hunks) do + local start, count = h[from .. "_start"], h[from .. "_count"] + local last = count == 0 and start or start + count - 1 + if last >= lnum then + break + end + out = out + h[to .. "_count"] - count + end + return out +end + +-- the HEAD line an index line sits at, for a line HEAD↔index leaves alone +---@param cached differ.Hunk[] -- HEAD↔index +---@param lnum integer -- index line +---@return integer +local function head_line(cached, lnum) + return lnum + M.shift(cached, lnum, "new") +end + +-- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot +-- show index content that differs from HEAD and worktree both: stage a change and then +-- put the worktree back or edit it again, or let the two diffs align a run of repeated +-- lines differently, and the change is real but off-screen. a false puts the note on +-- the winbar, which says how many hunks hold some of it rather than why +---@param union differ.Hunk[] +---@param cached differ.Hunk[] +---@param unstaged differ.Hunk[] +---@return boolean +function M.complete(union, cached, unstaged) + -- the index differs from HEAD and the worktree matches it + if #union == 0 and (#cached > 0 or #unstaged > 0) then + return false + end + -- unstaged or staged content sitting outside every hunk + local shows_new = covered(union, "new") + for _, h in ipairs(unstaged) do + for l = h.new_start, h.new_start + h.new_count - 1 do + if not shows_new[l] then + return false + end + end + end + local shows_old = covered(union, "old") + for _, h in ipairs(cached) do + for l = h.old_start, h.old_start + h.old_count - 1 do + if not shows_old[l] then + return false + end + end + end + -- an index line the worktree replaces shows only as the HEAD line it still is: one + -- the index added (or rewrote) reaches neither side, and one whose HEAD line the + -- union leaves as context is shown as committed + local added = covered(cached, "new") + for _, h in ipairs(unstaged) do + for l = h.old_start, h.old_start + h.old_count - 1 do + if added[l] then + return false -- a line only the index holds + end + if not shows_old[head_line(cached, l)] then + return false -- a line the worktree replaced, outside every hunk + end + end + end + -- the view implies an index of HEAD's context plus the marked lines. HEAD's length + -- cancels out of comparing that with HEAD↔index's net change, so the counts check + -- from hunks alone; a mismatch is a line the two pairs align differently + local shown, net = 0, 0 + local m = M.classify(union, cached, unstaged) + for _, h in ipairs(union) do + shown = shown - h.old_count + for l = h.old_start, h.old_start + h.old_count - 1 do + if not m.old[l] then + shown = shown + 1 + end + end + for l = h.new_start, h.new_start + h.new_count - 1 do + if m.new[l] then + shown = shown + 1 + end + end + end + for _, h in ipairs(cached) do + net = net + h.new_count - h.old_count + end + if shown ~= net then + return false -- the marked lines don't add up to the index + end + return true +end + +return M diff --git a/lua/differ/model/stage.lua b/lua/differ/model/stage.lua new file mode 100644 index 0000000..2c6cc7b --- /dev/null +++ b/lua/differ/model/stage.lua @@ -0,0 +1,162 @@ +-- what the index should hold after a hunk of a HEAD↔worktree diff is staged or +-- unstaged. the index's lines at each hunk come from marks.blocks where the index holds +-- every unchanged line between them; otherwise the op moves whole pair hunks. +-- pure lua, no vim API + +local apply = require("differ.model.apply") +local marks = require("differ.model.marks") +local text_util = require("differ.util.text") + +local M = {} + +-- the index's lines at each union hunk, and the union with a last line and its ending +-- read as one (apply.ended). the blocks are nil unless the index holds every unchanged +-- line between the hunks and rejoins to exactly its own text +---@param union differ.DiffModel -- HEAD↔worktree +---@param index string -- the index's text +---@return string[][]|nil blocks, differ.DiffModel ended +function M.index_blocks(union, index) + local ended = apply.ended(union) + local head_lines, index_lines = + text_util.ended_lines(union.old_text), text_util.ended_lines(index) + local blocks = marks.blocks(ended.hunks, head_lines, index_lines) + if not blocks or apply.join(ended, blocks) ~= index then + return nil, ended + end + return blocks, ended +end + +-- the hunks that meet `anchor` on the sides given, or the rest of them +---@param hunks differ.Hunk[] +---@param side "old"|"new" -- the side of `hunks` compared +---@param anchor differ.Hunk +---@param anchor_side "old"|"new" +---@param want boolean -- true picks the hunks that meet it, false the rest +---@return table +function M.select_hunks(hunks, side, anchor, anchor_side, want) + local applied = {} + for i, h in ipairs(hunks) do + applied[i] = marks.meets(h, side, anchor, anchor_side) == want + end + return applied +end + +-- where `hunk` sits in a fresh read of the union, by its line ranges +---@param union differ.DiffModel +---@param hunk differ.Hunk +---@return integer|nil +function M.hunk_index(union, hunk) + for i, u in ipairs(union.hunks) do + if + u.old_start == hunk.old_start + and u.old_count == hunk.old_count + and u.new_start == hunk.new_start + and u.new_count == hunk.new_count + then + return i + end + end + return nil +end + +-- the index's lines at each union hunk with `hunk`'s own replaced by the side the op +-- takes, or nil where the index can't be cut into blocks +---@param union differ.DiffModel +---@param cached differ.DiffModel +---@param hunk differ.Hunk +---@param take boolean +---@return string|nil +local function by_blocks(union, cached, hunk, take) + local blocks, ended = M.index_blocks(union, cached.new_text) + local i = M.hunk_index(union, hunk) + if not (blocks and i) then + return nil + end + blocks[i] = ended.hunks[i].old_lines + if take then + blocks[i] = ended.hunks[i].new_lines + end + return apply.join(ended, blocks) +end + +-- the text the index takes when `hunk` is staged (`take`) or unstaged. the index's lines +-- at the hunk become the hunk's new or old lines; an index that changes a line between +-- hunks moves by the pair hunks meeting it on the side that pair shares. `reaches` names +-- another union hunk such a pair hunk covers too, and then there is no text: staging by +-- hunk can't take one without the other. no text and no `reaches` is a hunk the pair the +-- op reads for lines up under a different union hunk, leaving the index where it is +---@param union differ.DiffModel -- HEAD↔worktree +---@param cached differ.DiffModel -- HEAD↔index +---@param unstaged differ.DiffModel -- index↔worktree +---@param hunk differ.Hunk +---@param take boolean +---@return string|nil text, integer|nil reaches +function M.next_index(union, cached, unstaged, hunk, take) + local text = by_blocks(union, cached, hunk, take) + if not text then + local from, side = unstaged, "new" + if not take then + from, side = cached, "old" + end + local reaches = marks.shared_with(union.hunks, hunk, from.hunks, side) + if reaches then + return nil, reaches + end + text = apply.splice(from, M.select_hunks(from.hunks, side, hunk, side, take)) + end + if text == cached.new_text then + return nil -- the op has nothing of this hunk to move on its own + end + return text +end + +-- the index text with its lines `first`..`last` back to HEAD's, where they sit inside +-- HEAD↔index hunk `ci` and that hunk's two sides are the same length, so each index line +-- has one HEAD line at the same place in it. every other hunk stays applied +---@param cached differ.DiffModel -- HEAD↔index +---@param ci integer +---@param first integer -- index lines +---@param last integer +---@return string +function M.drop_lines(cached, ci, first, last) + local hunks, applied = {}, {} ---@type differ.Hunk[], table + local function slice(lines, from, count) + local out = {} + for i = from, from + count - 1 do + out[#out + 1] = lines[i] + end + return out + end + for i, h in ipairs(cached.hunks) do + if i ~= ci then + hunks[#hunks + 1] = h + applied[#hunks] = true + else + ---@param from integer + ---@param to integer + ---@param take boolean + local function piece(from, to, take) + if to < from then + return + end + local at, count = from - h.new_start + 1, to - from + 1 + hunks[#hunks + 1] = { + old_start = h.old_start + at - 1, + old_count = count, + old_lines = slice(h.old_lines, at, count), + new_start = from, + new_count = count, + new_lines = slice(h.new_lines, at, count), + } + applied[#hunks] = take + end + piece(h.new_start, first - 1, true) + piece(first, last, false) + piece(last + 1, h.new_start + h.new_count - 1, true) + end + end + local split = { old_text = cached.old_text, new_text = cached.new_text, hunks = hunks } + return apply.splice(split --[[@as differ.DiffModel]], applied) +end + +return M diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 87e627c..d0c81f1 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -18,16 +18,14 @@ local CTRL_U = vim.api.nvim_replace_termcodes("", true, false, true) ---@type differ.Panel|nil -- the live panel, for runtime API (Panel.current()) local current = nil --- a file entry's identity across a list rebuild. the path alone isn't enough: a file --- changed on both pairs holds a Staged and an Unstaged row at once, so the pair is --- part of what makes the row that row +-- a file entry's identity across a list rebuild: every path holds one row ---@param entry differ.FileEntry|nil ---@return string|nil local function entry_key(entry) if not entry then return nil end - return (entry.staged and "s\0" or "u\0") .. entry.path + return entry.path end -- a `(glyph, hl)` provider backed by nvim-web-devicons, or nil when it's absent @@ -56,6 +54,15 @@ local STATUS_HL = { ["?"] = "differPanelUntracked", } +-- `one` takes the path; `many` the names of the files that lose content +---@class differ.panel.DropPrompt +---@field one string +---@field many string + +---@class differ.panel.DropPrompts +---@field stage differ.panel.DropPrompt +---@field unstage differ.panel.DropPrompt + ---@class differ.panel.Section ---@field title string|nil ---@field entries differ.FileEntry[] @@ -63,11 +70,15 @@ local STATUS_HL = { -- file-level staging hooks (slice C); supplied by the local frontend for the -- working-tree source, nil otherwise (rev-pair lists aren't stageable) ---@class differ.panel.Actions ----@field stage fun(entry: differ.FileEntry) +---@field stage fun(entry: differ.FileEntry): boolean|nil -- false: refused, nothing moved ---@field unstage fun(entry: differ.FileEntry) ----@field stage_all fun() +---@field stage_drops? fun(entry: differ.FileEntry): boolean -- staging loses content nothing else holds +---@field drop_prompts? differ.panel.DropPrompts -- what s/S and u/U ask when a drops hook says so +---@field unstage_drops? fun(entry: differ.FileEntry): boolean -- unstaging loses content nothing else holds +---@field stage_all fun(): boolean|nil -- false: refused, nothing moved ---@field unstage_all fun() ---@field discard fun(entry: differ.FileEntry) +---@field discard_prompt? fun(entry: differ.FileEntry): string|nil -- X's confirm where it isn't a discard ---@field reload fun(): differ.panel.Section[] -- recompute sections after an op ---@class differ.Panel @@ -93,6 +104,8 @@ local STATUS_HL = { ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- fired after a ]f/[f step, for an optional session hook (the pr forward-auto-mark) ---@field on_step fun(direction: "next"|"prev", left: differ.FileEntry|nil, new: differ.FileEntry)|nil +---@field review differ.panel.ReviewHooks|nil +---@field walk_order string[]|nil -- the file rows' keys, in list order, when the open file opened ---@field icon_for nil|fun(path: string): string|nil, string|nil ---@field position string ---@field height integer @@ -109,6 +122,7 @@ local STATUS_HL = { --- and the show() cursor while the sidebar is hidden (no window to read from) ---@field selected_key string|nil -- that file's identity, so render can re-derive the --- row after a rebuild shuffles it +---@field left_at integer|nil -- the row the opened file sat at when a rebuild dropped it local Panel = {} Panel.__index = Panel @@ -135,6 +149,7 @@ Panel.__index = Panel ---@field keymaps? table -- resolved panel action -> lhs ---@field extra_keymaps? differ.panel.ExtraMap[] -- session maps (pr viewed nav) ---@field on_step? fun(direction: "next"|"prev", left: differ.FileEntry|nil, new: differ.FileEntry) +---@field review? differ.panel.ReviewHooks -- the staging walk's memory of rows passed as they are ---@field icons? boolean -- filetype devicons (default true when available) ---@field listing? "tree"|"name" ---@field position? "bottom"|"top"|"left"|"right" @@ -142,6 +157,14 @@ Panel.__index = Panel ---@field width? integer ---@field progress? boolean -- file-position meter in the panel winbar (default on) +-- rows the review walk has stepped off with nothing left for its key, remembered by the +-- session until the file or its index entry changes. `staged` is the walk: false for s +---@class differ.panel.ReviewHooks +---@field accept fun(entry: differ.FileEntry, staged: boolean) +---@field accepted fun(entry: differ.FileEntry, staged: boolean): boolean +---@field has_local fun(entry: differ.FileEntry): boolean -- the row has a local view to send it to +---@field local_paths fun(): table -- the paths whose local view has lines to show + ---@class differ.panel.ExtraMap ---@field spec string|string[]|false -- resolved lhs (a keymaps value) ---@field fn fun() @@ -180,6 +203,7 @@ function Panel.new(opts) actions = opts.actions, extra_keymaps = opts.extra_keymaps, on_step = opts.on_step, + review = opts.review, keymaps = vim.tbl_extend( "force", require("differ.config").defaults.keymaps, @@ -217,25 +241,17 @@ function Panel:focus_first_changed() end -- move the cursor to `path`'s file row if it's currently rendered, returning whether --- it was found; lets :Differ open on the current file rather than the first. --- a path can hold two rows (an "MM" file lists under Staged and Unstaged), and the --- Staged one comes first; `prefer_unstaged` takes the unstaged row instead, falling --- back to the staged one when that's the only pair +-- it was found; lets :Differ open on the current file rather than the first ---@param path string -- repo-relative ----@param prefer_unstaged? boolean ---@return boolean -function Panel:focus_file(path, prefer_unstaged) - local first, pick +function Panel:focus_file(path) + local pick for i, m in ipairs(self.meta) do if m.kind == "file" and m.entry.path == path then - first = first or i - if not (prefer_unstaged and m.entry.staged) then - pick = i - break - end + pick = i + break end end - pick = pick or first if not pick then return false end @@ -355,7 +371,11 @@ function Panel:render() -- left the change set outright clears it: keeping the number would leave it naming -- whatever slid into that row, or pointing past the end of a shorter list if self.selected_key then + local was = self.selected_row self.selected_row = self:_row_of_key(self.selected_key) + if not self.selected_row then + self.left_at = was or self.left_at + end end vim.bo[self.bufnr].modifiable = true @@ -657,6 +677,8 @@ function Panel:_open(entry, keep_focus) -- every selection path sets selected_row then lands here, so this is the one -- place the opened file's identity has to be recorded for render to re-derive it self.selected_key = entry_key(entry) + self.left_at = nil + self.walk_order = self:_file_keys() end if not keep_focus and self.winid and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_set_current_win(self.winid) @@ -705,25 +727,25 @@ end -- the entries a stage/unstage/discard acts on from the cursor row: the single file -- under the cursor, every file beneath a directory row, or every file in a section -- when the cursor is on its header (the only target for a section whose shared deep --- prefix is stripped to a subtitle, leaving no dir row). also returns a label for the --- discard confirm. empty when the cursor isn't on a file, dir, or header row ----@return differ.FileEntry[] entries, string|nil label +-- prefix is stripped to a subtitle, leaving no dir row). empty when the cursor isn't +-- on a file, dir, or header row +---@return differ.FileEntry[] function Panel:_op_targets() local m = self.winid and self.meta[vim.api.nvim_win_get_cursor(self.winid)[1]] if not m then - return {}, nil + return {} end if m.kind == "file" then - return { m.entry }, m.entry.path + return { m.entry } end if m.kind == "dir" then - return self:_dir_entries(m), m.dir_path .. "/" + return self:_dir_entries(m) end if m.kind == "header" then local sec = self.sections[m.section] - return sec and vim.list_slice(sec.entries) or {}, m.title or "section" + return sec and vim.list_slice(sec.entries) or {} end - return {}, nil + return {} end -- re-read the model (after a stage op or on focus) and repaint, keeping the cursor @@ -768,23 +790,81 @@ function Panel:stage_op(op) if not self.actions then return end + local verb = "unstage" ---@type "stage"|"unstage" + if op == "stage" or op == "stage_all" then + verb = "stage" + end local paths ---@type table|nil -- nil: the op moved every file if op == "stage_all" or op == "unstage_all" then - self.actions[op]() + if not self:_drop_confirmed(verb, self:_all_entries()) then + return + end + if self.actions[op]() == false then + return + end else local entries = self:_op_targets() if #entries == 0 then return end + if not self:_drop_confirmed(verb, entries) then + return + end paths = {} for _, e in ipairs(entries) do - self.actions[op](e) - paths[e.path] = true + if self.actions[op](e) ~= false then + paths[e.path] = true + end + end + if next(paths) == nil then + return end end self:_after_stage_op(paths) end +---@return differ.FileEntry[] +function Panel:_all_entries() + local entries = {} + for _, sec in ipairs(self.sections) do + vim.list_extend(entries, sec.entries) + end + return entries +end + +-- s/S or u/U on `entries`: asks first when any of them holds staged content nothing +-- else has +---@param verb "stage"|"unstage" +---@param entries differ.FileEntry[] +---@return boolean +function Panel:_drop_confirmed(verb, entries) + local drops = self.actions[verb .. "_drops"] + local prompts = self.actions.drop_prompts + if not drops or not prompts then + return true + end + local lost = {} ---@type string[] + for _, e in ipairs(entries) do + if drops(e) then + lost[#lost + 1] = e.path + end + end + if #lost == 0 then + return true + end + local prompt + if #entries == 1 then + prompt = prompts[verb].one:format(lost[1]) + else + local names = table.concat(vim.list_slice(lost, 1, 3), ", ") + if #lost > 3 then + names = ("%s, +%d more"):format(names, #lost - 3) + end + prompt = prompts[verb].many:format(names) + end + return vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 +end + -- reload the list after one of the panel's own staging ops, then let the session -- re-source the diff it drives. `paths` is what the op moved, nil meaning every file ---@param paths table|nil @@ -811,13 +891,18 @@ function Panel:discard() if not self.actions then return end - local entries, label = self:_op_targets() + local entries = self:_op_targets() if #entries == 0 then return end - local what = #entries == 1 and discard_label(entries[1]) - or ("%s (%d files)"):format(label, #entries) - local choice = vim.fn.confirm(("Discard changes to %s?"):format(what), "&Yes\n&No", 2) + local prompt = "Discard changes to these files?" + if #entries == 1 then + prompt = ("Discard changes to %s?"):format(discard_label(entries[1])) + if self.actions.discard_prompt then + prompt = self.actions.discard_prompt(entries[1]) or prompt + end + end + local choice = vim.fn.confirm(prompt, "&Yes\n&No", 2) if choice == 1 then local paths = {} for _, e in ipairs(entries) do @@ -906,7 +991,13 @@ function Panel:goto_file(direction, keep_focus, wrap) -- opened row so ]f/[f keeps working with the panel hidden local from = self:is_open() and vim.api.nvim_win_get_cursor(self.winid)[1] or self.selected_row + or self.left_at or self:_first_file_line() + -- the opened file left the list and the cursor hasn't moved off where it was: the + -- row that slid into its place is the next one + if direction == "next" and not self.selected_row and from == self.left_at then + from = from - 1 + end local i, wrapped = self:_file_row(from, direction, wrap) if not i then return false @@ -942,6 +1033,14 @@ function Panel:_edge_file_row(edge) return row end +-- whether a row has something left to stage: anything but a Staged row, and every row +-- of a source without staging, whose entries carry no review state +---@param e differ.FileEntry +---@return boolean +local function has_unstaged(e) + return e.review ~= "staged" +end + -- move the cursor to the first unstaged file row, skipping the Staged section so -- :Differ lands on the first thing left to review, falling back to the first -- first file when everything is staged. mirrors the per-file _first_review_line, which @@ -951,7 +1050,7 @@ function Panel:focus_first_unstaged() local i = self:_file_row(0, "next", false) while i do local e = self.meta[i].entry - if e and not e.staged then + if e and has_unstaged(e) then pcall(vim.api.nvim_win_set_cursor, self.winid, { i, 0 }) return end @@ -960,34 +1059,154 @@ function Panel:focus_first_unstaged() self:focus_first_changed() end --- the review flow's file step: the nearest row in `direction` with something left to do, --- wrapping past the list ends. the open file is skipped, since re-opening it would +-- whether a row has hunks left to stage (`staged` false) or unstage (true). a Partial +-- row holds both; a conflict holds neither, since the merge tool resolves it +---@param e differ.FileEntry +---@param staged boolean +---@return boolean +local function has_review_work(e, staged) + if e.review == "conflict" then + return false + end + if staged then + return e.review == "staged" or e.review == "partial" + end + return has_unstaged(e) +end + +-- every file row's key, in list order +---@return string[] +function Panel:_file_keys() + local out = {} + for _, m in ipairs(self.meta) do + if m.kind == "file" then + out[#out + 1] = entry_key(m.entry) + end + end + return out +end + +-- the keys the walk tries from the open file, in `direction`: the rest of the list as +-- it stood when the file opened, then round from the other end, then any file listed +-- since. staging moves a finished file up into Staged, and stepping from where it +-- landed would restart the walk at the top. the second value is where the wrap starts +---@param direction "next"|"prev" +---@return string[] keys, integer wrap_at +function Panel:_walk_keys(direction) + local order = self.walk_order or self:_file_keys() + local at = 0 + for i, key in ipairs(order) do + if key == self.selected_key then + at = i + end + end + local out, seen = {}, {} + local function add(key) + if not seen[key] then + seen[key] = true + out[#out + 1] = key + end + end + local n, step = #order, direction == "prev" and -1 or 1 + for k = 1, n do + add(order[((at - 1 + step * k) % n) + 1]) + end + -- where the walk comes round the end of the remembered order. the open row isn't in + -- it when `at` is 0, and then there is no place in it to have come round from, so + -- the wrap sits past every key and nothing the walk reaches claims one + local wrap_at + if at > 0 then + wrap_at = (direction == "prev" and at - 1 or n - at) + 1 + end + for _, key in ipairs(self:_file_keys()) do + add(key) + end + return out, wrap_at or #out + 1 +end + +-- whether the walk still has something for its key on `e`: its status says so, and +-- the walk hasn't already stepped off it with nothing left +---@param e differ.FileEntry +---@param staged boolean +---@return boolean +function Panel:_walk_wants(e, staged) + if not has_review_work(e, staged) then + return false + end + return not (self.review and self.review.accepted(e, staged)) +end + +-- the open file has nothing left for the walk's key: remember it as passed +---@param staged boolean +function Panel:accept_review(staged) + local row = self:_row_of_key(self.selected_key) + local e = row and self.meta[row].entry + if e and self.review and has_review_work(e, staged) then + self.review.accept(e, staged) + end +end + +-- the listed rows the s walk has passed with work it couldn't take and a local view +-- with something to show for it, in list order. a row whose local view would open empty +-- is left out: the list is read as where to go next, and a row left with only a move +-- has nothing for dw to answer with +---@return differ.FileEntry[] +function Panel:review_leftover() + local out = {} + if not self.review then + return out + end + local shows = self.review.local_paths() + for _, m in ipairs(self.meta) do + if + m.kind == "file" + and has_review_work(m.entry, false) + and not self:_walk_wants(m.entry, false) + and self.review.has_local(m.entry) + and shows[m.entry.path] + then + out[#out + 1] = m.entry + end + end + return out +end + +-- whether the open row's local view has something left to show: the s walk stops there +-- rather than stepping past it, unless ]f has already left the row +---@return boolean +function Panel:has_local_work() + local row = self:_row_of_key(self.selected_key) + local e = row and self.meta[row].entry + if not (e and self.review and self.review.has_local(e)) then + return false + end + if self.review.accepted(e, false) then + return false + end + return self.review.local_paths()[e.path] == true +end + +-- the review flow's file step: the next file in `direction` with something left to do, +-- in the order _walk_keys gives. the open file is skipped, since re-opening it would -- re-source its frozen diff and drop the staging marks ---@param direction "next"|"prev" ---@param staged boolean -- the pair hunted: false for a file with hunks left to stage ---@param keep_focus boolean|nil ---@return boolean moved function Panel:step_review(direction, staged, keep_focus) - -- from the selection rather than the panel cursor: this flow is driven from the - -- diff window, so where the review sits beats where the sidebar is parked - local from = self.selected_row or self:_first_file_line() - local row, wrapped = from, false - for _ = 1, #self.meta do - local next_row, crossed = self:_file_row(row, direction, true) - if not next_row or next_row == from then - break -- the list holds no file rows, or we're back where we started - end - wrapped = wrapped or crossed - local e = self.meta[next_row].entry - if e and (e.staged or false) == staged and entry_key(e) ~= self.selected_key then - self.selected_row = next_row + local keys, wrap_at = self:_walk_keys(direction) + for i, key in ipairs(keys) do + local row = self:_row_of_key(key) + local e = row and self.meta[row].entry + if e and key ~= self.selected_key and self:_walk_wants(e, staged) then + self.selected_row = row if self:is_open() then - vim.api.nvim_win_set_cursor(self.winid, { next_row, 0 }) + vim.api.nvim_win_set_cursor(self.winid, { row, 0 }) end if self:_open(e, keep_focus) then -- the review flow's own wrap notice: `goto_file`'s would claim the first -- file, and this landed on the first one with anything left to do - if wrapped then + if i >= wrap_at then vim.notify( direction == "next" and "differ: wrapped to the first file left to stage" or "differ: wrapped to the last file left to unstage", @@ -996,14 +1215,8 @@ function Panel:step_review(direction, staged, keep_focus) end return true end - -- the entry was stale, and opening it refreshed the list out from under the - -- walk: the rows behind us name different files now, so start again from the - -- selection, which a failed open left on the file we came from. the stale - -- entry is gone from the rebuilt list, so each retry has one less to meet - from = self.selected_row or self:_first_file_line() - row = from - else - row = next_row + -- the entry was stale, and opening it refreshed the list: the keys still + -- name files, so the walk goes on through them, skipping any that left end end return false @@ -1453,29 +1666,21 @@ function Panel:is_alive() return vim.api.nvim_buf_is_valid(self.bufnr) end --- the meta row holding `key`, preferring its own pair and falling back to the same --- path's other pair: staging a file's last unstaged hunk moves its row from Unstaged --- to Staged, and the cursor should follow the file rather than sit on whatever row --- slid into its place. nil when the path left the list entirely +-- the meta row holding `key`: staging can move a file's row to another section, and +-- the cursor should follow the file rather than sit on whatever row slid into its +-- place. nil when the path left the list entirely ---@param key string|nil ---@return integer|nil function Panel:_row_of_key(key) if not key then return nil end - local path = key:sub(3) - local same_path for i, m in ipairs(self.meta) do - if m.kind == "file" then - local k = entry_key(m.entry) - if k == key then - return i - elseif not same_path and m.entry.path == path then - same_path = i - end + if m.kind == "file" and entry_key(m.entry) == key then + return i end end - return same_path + return nil end -- the cursor's restorable position: the file entry under it (by identity, so it diff --git a/lua/differ/panel/tree.lua b/lua/differ/panel/tree.lua index 6ca39ad..9910bb1 100644 --- a/lua/differ/panel/tree.lua +++ b/lua/differ/panel/tree.lua @@ -5,12 +5,17 @@ local M = {} +---@alias differ.panel.Review "staged"|"partial"|"unstaged"|"conflict" + ---@class differ.FileEntry ---@field path string ---@field status "A"|"M"|"D"|"R"|"C"|"U"|"?" ---@field additions integer ---@field deletions integer ----@field staged boolean|nil -- which local section it belongs to +---@field x string|nil -- worktree panels: git's index status letter +---@field y string|nil -- worktree panels: git's worktree status letter +---@field review differ.panel.Review|nil -- what the row leaves to review; nil off a staging source +---@field kept boolean|nil -- a staged deletion whose file is still on disk, untracked ---@field previous_path string|nil -- renames/copies ---@field viewed boolean|nil -- PR only diff --git a/lua/differ/pr/comment.lua b/lua/differ/pr/comment.lua index e12dad0..2dbeefe 100644 --- a/lua/differ/pr/comment.lua +++ b/lua/differ/pr/comment.lua @@ -82,7 +82,7 @@ function M.range_anchor(map, row1, row2, colside) if a.side == "LEFT" and b.side == "RIGHT" then return { start_side = "LEFT", start_line = a.line, side = "RIGHT", line = b.line } end - return nil, "mixed-side selection GitHub can't represent" + return nil, "mixed-side selection github can't represent" end -- ── gesture entry points (vim surface) ──────────────────────────────────────────── @@ -199,7 +199,7 @@ function M.reply(session) local row = vim.api.nvim_win_get_cursor(win)[1] local anchor = require("differ.pr.threads").anchor_at(session, buf, row) if not anchor then - return notify("no thread under the cursor to reply to") + return notify("no thread on this line to reply to") end pick_thread(anchor, "Reply to which thread?", function(thread) if not guard.owns(session) then @@ -251,7 +251,7 @@ function M.delete(session) local row = vim.api.nvim_win_get_cursor(win)[1] local anchor = require("differ.pr.threads").anchor_at(session, buf, row) if not anchor then - return notify("no thread under the cursor to delete from") + return notify("no thread on this line to delete from") end pick_thread(anchor, "Delete from which thread?", function(thread) if not guard.owns(session) then @@ -282,7 +282,7 @@ function M.compose(session, opts) local base = opts.in_reply_to and "Reply" or (session.review_id and "Comment (draft)" or "Comment (posts immediately)") require("differ.ui.compose").open({ - title = opts.stale and (base .. " — head moved, re-submit") or base, + title = opts.stale and (base .. " · head moved, re-submit") or base, initial = opts.initial, layout = session.view and session.view.layout, anchor_win = opts.anchor_win, @@ -309,7 +309,10 @@ function M.post(session, opts, body) else local a = opts.anchor if not opts.path then - return notify("lost track of the file this comment anchors to; nothing posted") + return notify( + "lost track of the file this comment anchors to; nothing posted", + vim.log.levels.WARN + ) end args.path = opts.path args.side, args.line = a.side, a.line diff --git a/lua/differ/pr/init.lua b/lua/differ/pr/init.lua index 9e77a32..1d5d97d 100644 --- a/lua/differ/pr/init.lua +++ b/lua/differ/pr/init.lua @@ -66,7 +66,10 @@ local function notify_err(err) local code = (err and err.code) or "internal" local msg = (err and err.message) or code local hint = M.error_hint(err) - notify(hint and (msg .. " (" .. hint .. ")") or msg, vim.log.levels.ERROR) + if hint and not msg:gsub("`", ""):find(hint, 1, true) then -- gh's own text may say it + msg = msg .. " (" .. hint .. ")" + end + notify(msg, vim.log.levels.ERROR) end -- exposed for the review/comment modules, which share the same typed-error treatment @@ -431,7 +434,7 @@ function M.resolve() end target = target or anchor.threads[1] if target.is_pending or not target.thread_id then - return notify("this thread can't be resolved yet") + return notify("a draft thread can't be resolved until the review is submitted") end local threads = require("differ.pr.threads") local new_state = not target.resolved @@ -482,7 +485,7 @@ function M.handle_conflict(on_ready) show_file(cur) -- re-source the diff + overlay at the new head end notify( - "the PR head moved; refreshed to the latest - review and re-submit", + "the PR head moved; refreshed to the latest, review and re-submit", vim.log.levels.WARN ) if on_ready then @@ -532,7 +535,7 @@ end ---@return table|nil view local function editable_view() if not (session and session.view and session.view:is_open()) then - notify("no active pull request diff") + notify("no active pull request diff", vim.log.levels.WARN) return nil end if not session.root then @@ -664,7 +667,7 @@ local function adopt_pending_review(pr) if type(review_id) == "string" and review_id ~= "" then s.review_id = review_id notify( - "you have a pending review here - comments are drafts (:Differ pr review resume to manage)" + "you have a pending review here; comments are drafts (:Differ pr review resume to manage it)" ) end end) @@ -1006,7 +1009,11 @@ function M.open(opts) return notify_err(lerr) end if not prs or #prs == 0 then - return notify("no pull requests for " .. coords.owner .. "/" .. coords.repo) + local msg = "no pull requests for " .. coords.owner .. "/" .. coords.repo + if opts.filter then + msg = ("%s (%s)"):format(msg, (opts.filter:gsub("_", " "))) + end + return notify(msg) end pick(coords, prs, { land = opts.land, review = opts.review }) end) @@ -1155,7 +1162,7 @@ function M.merge(method_arg) return notify("no active pull request") end local method = M.merge_method(method_arg) - confirm(('merge "%s" via %s?'):format(pr_title(), method), function() + confirm(('Merge "%s" via %s?'):format(pr_title(), method), function() if not session then return end @@ -1183,6 +1190,14 @@ function M.merge(method_arg) end) end +---@type table +local STATE_DONE = { + ready = "marked ready", + draft = "converted to draft", + close = "closed", + reopen = "reopened", +} + -- :Differ pr ready|draft|close|reopen - map the verb to a state and transition. close -- confirms (destructive); the reversible verbs act immediately. on success the session's -- cached state/draft flags follow the server's echoed state @@ -1207,11 +1222,11 @@ function M.set_state(verb) local new_state = (res and res.state) or state s.pr_meta.state = new_state s.pr_meta.draft = new_state == "draft" - notify("pull request " .. new_state) + notify("pull request " .. STATE_DONE[verb]) end) end if M.is_destructive(verb) then - confirm(('close "%s"?'):format(pr_title()), run) + confirm(('Close "%s"?'):format(pr_title()), run) else run() end @@ -1237,10 +1252,8 @@ function M.checkout() end local ok, err = git.checkout(root, ref, s.pr.number) if not ok then - return notify( - "checkout failed: " .. (err and vim.trim(err) or ref), - vim.log.levels.ERROR - ) + local exec = require("differ.git.exec") + return notify(exec.with_stderr("checkout failed", err), vim.log.levels.ERROR) end notify("checked out " .. ref) end) @@ -1308,7 +1321,7 @@ function M.resume(arg) end) end if not session then - return notify("no active pull request - open one with :Differ pr review ") + return notify("no active pull request; open one with :Differ pr review ") end require("differ.pr.review").reattach(session) end diff --git a/lua/differ/pr/overview.lua b/lua/differ/pr/overview.lua index 14e41b0..452eb6e 100644 --- a/lua/differ/pr/overview.lua +++ b/lua/differ/pr/overview.lua @@ -388,7 +388,9 @@ local function set_keymaps(b) end local target = target_at(vim.api.nvim_win_get_cursor(0)[1]) if not target then - return require("differ.pr").notify("nothing here to answer; ga comments on the PR") + return require("differ.pr").notify( + "nothing here to reply to or quote; ga comments on the PR" + ) end answer(s, target, quote and quoted(target.author, split_body(target.body)) or nil) end @@ -408,7 +410,9 @@ local function set_keymaps(b) local lo, hi = math.min(r1, r2), math.max(r1, r2) local target = target_at(lo) if not target then - return require("differ.pr").notify("nothing here to answer; ga comments on the PR") + return require("differ.pr").notify( + "nothing here to reply to or quote; ga comments on the PR" + ) end local picked = {} for _, l in ipairs(vim.api.nvim_buf_get_lines(b, lo - 1, hi, false)) do diff --git a/lua/differ/pr/review.lua b/lua/differ/pr/review.lua index 8c77cfa..9835762 100644 --- a/lua/differ/pr/review.lua +++ b/lua/differ/pr/review.lua @@ -52,7 +52,7 @@ function M.start(session, opts) return require("differ.pr").notify_err(err) end session.review_id = res and res.review_id - notify("review started - comments are drafts until you submit") + notify("review started; comments are drafts until you submit") end) end @@ -87,10 +87,16 @@ function M.reattach(session, opts) session.panel:goto_path(session.entries[next_up].path) end end - notify("resumed your pending review - comments are drafts") + notify("resumed your pending review; comments are drafts") end) end +local EVENT_LABELS = { + COMMENT = "Comment", + APPROVE = "Approve", + REQUEST_CHANGES = "Request changes", +} + -- :Differ pr review submit - finalise the draft as one batch. pick an event, author a summary -- in the compose float, then submit with the session head guard. on success the drafts -- become published (re-fetched) and immediate mode resumes @@ -101,12 +107,15 @@ function M.submit(session) end vim.ui.select({ "COMMENT", "APPROVE", "REQUEST_CHANGES" }, { prompt = "Submit review as", + format_item = function(event) + return EVENT_LABELS[event] + end, }, function(event) if not event then return -- cancelled the pick; nothing submitted end require("differ.ui.compose").open({ - title = "Review summary · " .. event, + title = "Review summary · " .. EVENT_LABELS[event], layout = session.view and session.view.layout, anchor_win = diff_win(session), on_submit = function(body) @@ -131,15 +140,13 @@ function M._do_submit(session, event, body) end if err then if err.code == "conflict" then - return require("differ.pr").handle_conflict(function() - notify("re-submit your review against the refreshed head", vim.log.levels.WARN) - end) + return require("differ.pr").handle_conflict() end return require("differ.pr").notify_err(err) end session.review_id = nil repaint_threads(session) - notify("review submitted · " .. event) + notify("review submitted · " .. EVENT_LABELS[event]:lower()) end) end diff --git a/lua/differ/render/init.lua b/lua/differ/render/init.lua index 27ddf68..062fe7e 100644 --- a/lua/differ/render/init.lua +++ b/lua/differ/render/init.lua @@ -34,6 +34,37 @@ local RENDERERS = { split = "differ.render.split", } +-- flag the changed line a side ends on without a newline, where the other side's +-- ending differs: that's when two lines of the same text make a hunk. a file missing +-- its newline on both sides has a hunk for its content, and no flag +---@param model differ.DiffModel +---@param result differ.RenderResult +local function flag_no_eol(model, result) + local to_lines = require("differ.util.text").to_lines + ---@param text string + ---@return boolean + local function unterminated(text) + return text ~= "" and text:sub(-1) ~= "\n" + end + local last = {} ---@type table + if unterminated(model.old_text) == unterminated(model.new_text) then + return + end + for _, side in ipairs({ "old", "new" }) do + local text = model[side .. "_text"] + if unterminated(text) then + last[side] = #to_lines(text) + end + end + for _, col in ipairs(result.columns) do + for _, line in ipairs(col.map.lines) do + if last[line.kind] and line[line.kind] == last[line.kind] then + line.no_eol = true + end + end + end +end + -- render a model under the given layout ---@param model differ.DiffModel ---@param opts { layout: differ.Layout, context: number, deep_diff: table } @@ -45,7 +76,9 @@ function M.render(model, opts) end ---@type differ.Renderer local renderer = require(mod).render - return renderer(model, opts) + local result = renderer(model, opts) + flag_no_eol(model, result) + return result end return M diff --git a/lua/differ/render/linemap.lua b/lua/differ/render/linemap.lua index 27632ee..acd7af5 100644 --- a/lua/differ/render/linemap.lua +++ b/lua/differ/render/linemap.lua @@ -13,6 +13,7 @@ ---@field new integer|nil ---@field hunk integer|nil -- index into DiffModel.hunks ---@field spans differ.SubSpan[]|nil -- word-level changed regions (old/new only) +---@field no_eol boolean|nil -- a side's last line, with no newline after it ---@class differ.LineMap ---@field lines differ.RailLine[] -- indexed by buffer lnum (1-based) diff --git a/lua/differ/sidecar/init.lua b/lua/differ/sidecar/init.lua index 499d663..d08815a 100644 --- a/lua/differ/sidecar/init.lua +++ b/lua/differ/sidecar/init.lua @@ -271,11 +271,9 @@ function flush_queue() end end --- the next request() spins a fresh client, so a rebuilt binary heals a mismatch +-- the next request() spins a fresh client, so a rebuilt binary heals a mismatch. the +-- queued callers report `why`; the request that started the client is always one local function handshake_failed(why) - vim.schedule(function() - vim.notify("differ: sidecar " .. why, vim.log.levels.ERROR) - end) teardown(mkerr("internal", why)) end @@ -293,12 +291,12 @@ function handshake() if not client.running then return end - handshake_failed("handshake failed: " .. (err.message or err.code)) + handshake_failed("sidecar handshake failed: " .. (err.message or err.code)) return end if type(result) ~= "table" or result.protocol ~= PROTOCOL then handshake_failed( - "protocol mismatch — rebuild your sidecar (run `make go-build` or `go install`)" + "sidecar protocol mismatch; rebuild it with make go-build or go install" ) return end @@ -384,9 +382,6 @@ function schedule_restart() local self = client client.attempts = client.attempts + 1 if client.attempts > MAX_ATTEMPTS then - vim.schedule(function() - vim.notify("differ: sidecar keeps crashing; giving up", vim.log.levels.ERROR) - end) -- a request made before the handshake sits in the queue, so no per-exit failure -- reaches it and this is the only error a caller sees local why = "sidecar unavailable" diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index ac4cf6f..2b4a4a5 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -121,6 +121,13 @@ local function status_groups(p) differPanelCountDelete = { fg = p.red }, -- the staged-hunk gutter glyph: green for "in the index" differStagedSign = { fg = p.green }, + -- a hunk touching staged content the whole change can't show, in the staged cell + differHiddenSign = { fg = p.orange, bold = true }, + -- the gutter glyph on a line its side ends on with no newline after it: the + -- changed colour, since that ending is what changed + differNoEol = { fg = p.yellow }, + -- the winbar badge on a view that isn't the whole change + differViewBadge = { fg = p.green, bold = true, reverse = true }, } end @@ -275,7 +282,7 @@ local function check_termguicolors() end warned = true vim.notify( - "differ.nvim needs 'termguicolors' for its diff colours; without it the diff renders uncoloured", + "differ: needs 'termguicolors' for diff colours; without it the diff renders uncoloured", vim.log.levels.WARN ) end diff --git a/lua/differ/ui/statuscolumn.lua b/lua/differ/ui/statuscolumn.lua index d31bc21..146331f 100644 --- a/lua/differ/ui/statuscolumn.lua +++ b/lua/differ/ui/statuscolumn.lua @@ -4,6 +4,8 @@ local M = {} local STAGED_GLYPH = "▌" +local HIDDEN_GLYPH = "!" +local NO_EOL_GLYPH = "¬" ---@type table -- bufnr -> pre-formatted rail string per lnum local rails = {} @@ -11,6 +13,12 @@ local rails = {} ---@type table> -- bufnr -> set of staged lnums local staged = {} +---@type table> -- bufnr -> lnums opening a hunk marked `!` +local hidden = {} + +---@type table> -- bufnr -> lnums ending a side with no newline +local no_eol = {} + -- pre-format the gutter string for every line of a column from its map. pure, so -- the statuscolumn callback only does an O(1) index per redraw. a unified -- column shows both rails (old left / new right); a side column shows only its @@ -64,15 +72,41 @@ function M.set_staged(bufnr, lines) staged[bufnr] = lines end +-- register the lines that open a hunk marked `!`; they take the staged cell's place +---@param bufnr integer +---@param lines table|nil +function M.set_hidden(bufnr, lines) + hidden[bufnr] = lines +end + +-- register the lines that end their side without a newline after them, from the map +-- the column was rendered from. nil where none do, so the cell costs nothing +---@param bufnr integer +---@param map differ.LineMap +function M.set_no_eol(bufnr, map) + local lines + for i, l in ipairs(map.lines) do + if l.no_eol then + lines = lines or {} + lines[i] = true + end + end + no_eol[bufnr] = lines +end + -- drop a buffer's cached rail strings ---@param bufnr integer function M.clear(bufnr) rails[bufnr] = nil staged[bufnr] = nil + hidden[bufnr] = nil + no_eol[bufnr] = nil end --- statuscolumn callback: return the rail string for the current line, prefixed by --- the staged-gutter cell when this is a staging-capable view +-- statuscolumn callback: return the rail string for the current line, prefixed by the +-- sign cell where the view has one. a line can carry only one sign: `!` names staged +-- content the diff can't show, `¬` a line its side ends on without a newline, and `▌` +-- a line the index holds, which the line's own shading says as well ---@return string function M.render() -- virtual lines (our thread overlay's virt_lines) reuse the anchor's lnum; without @@ -86,11 +120,19 @@ function M.render() return "" end local s = cache[vim.v.lnum] or "" - local sset = buf and staged[buf] - if sset then - return (sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") .. s + local sset, eset = buf and staged[buf], buf and no_eol[buf] + if not (sset or eset) then + return s + end + local hset = hidden[buf] + if hset and hset[vim.v.lnum] then + return "%#differHiddenSign#" .. HIDDEN_GLYPH .. "%*" .. s + end + if eset and eset[vim.v.lnum] then + return "%#differNoEol#" .. NO_EOL_GLYPH .. "%*" .. s end - return s + return (sset and sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") + .. s end return M diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index f24fced..fea8ab1 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -70,15 +70,58 @@ function M.diff() -- while reviewing (not just in the compose window) local draft = require("differ.pr").review_status(buf) local badge = draft and ("%#differReviewDraft#● " .. draft .. "%* ") or "" - return (" %s %%=%s%s hunk %d/%d "):format( + local note = M.hidden_note(view) or view.model.banner + local noted = note and ("%#WarningMsg#" .. esc(note) .. "%* ") or "" + local badge_text = view.staging and view.staging.badge + local tag = badge_text and ("%#differViewBadge# " .. esc(badge_text) .. " %* ") or "" + return (" %s%s %%=%s%s%s %shunk %d/%d "):format( + tag, esc(vim.fn.fnamemodify(view.model.path, ":t")), badge, + noted, HUNK_MARK, + M.tally(view), k, total ) end +-- "3 staged · 1 partial · " from the hunks' staged states, zero counts left out. +-- empty once every hunk is staged, and off a hunk-staging view +---@param view differ.View +---@return string +function M.tally(view) + local counts = view:hunk_tally() + if not counts or counts.staged == counts.total then + return "" + end + local out = "" + for _, state in ipairs({ "staged", "partial" }) do + if counts[state] > 0 then + out = out .. ("%d %s · "):format(counts[state], state) + end + end + return out +end + +-- the note on staged content the view can't show: how many hunks it sits in, and the +-- key to the local view where there is one +---@param view differ.View +---@return string|nil +function M.hidden_note(view) + local staging = view.staging + if not (staging and staging.hidden) then + return nil + end + local where = staging.hidden_in or {} + local text = #where > 0 and ("%d hidden"):format(#where) or "hidden" + local key = staging.toggle_local and require("differ.ui.help").fmt(view.keymaps.toggle_local) + if not key then + return text + end + return ("%s (%s)"):format(text, key) +end + -- panel winbar: a bar plus "file K/N" for the cursor's position in the file list ---@return string function M.panel() diff --git a/lua/differ/util/text.lua b/lua/differ/util/text.lua index 18accc8..6be6423 100644 --- a/lua/differ/util/text.lua +++ b/lua/differ/util/text.lua @@ -54,12 +54,38 @@ function M.truncate_end(s, max) return s:sub(1, max - 1) .. "…" -- "…" is one display column end --- vim.text.diff is line-oriented; an unterminated final line reads as changed, --- so normalise to newline-terminated before diffing +-- set on a last line with no newline after it, so `c` and `c\n` compare unequal as they +-- do in git's diff. a NUL never reaches a line: is_binary text isn't read by line +M.NO_EOL = "\0" + +-- `text`'s lines, the last carrying NO_EOL when the text doesn't end in a newline ---@param text string +---@return string[] +function M.ended_lines(text) + local lines = M.to_lines(text) + if #lines > 0 and text:sub(-1) ~= "\n" then + lines[#lines] = lines[#lines] .. M.NO_EOL + end + return lines +end + +-- the text ended_lines reads `lines` from: a newline after each line but one that +-- carries NO_EOL +---@param lines string[] ---@return string -function M.ensure_trailing_nl(text) - if text == "" or text:sub(-1) == "\n" then +function M.ended_text(lines) + if #lines == 0 then + return "" + end + local out = {} + for i, line in ipairs(lines) do + if line:sub(-1) == M.NO_EOL then + line = line:sub(1, -2) + end + out[i] = line + end + local text = table.concat(out, "\n") + if lines[#lines]:sub(-1) == M.NO_EOL then return text end return text .. "\n" diff --git a/lua/differ/view.lua b/lua/differ/view.lua index fd066ab..eec4613 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -26,6 +26,8 @@ local CTRL_U = vim.api.nvim_replace_termcodes("", true, false, true) -- where the first narrow step lands when coming down from whole-file, which has no -- finite neighbour to decrement local FULL_STEP_DOWN = 10 +-- the display width the walk's last word gives to naming leftover files +local LEFTOVER_WIDTH = 40 local set_wo = require("differ.util.win").set_local @@ -62,34 +64,47 @@ local armed_view = nil ---@field side differ.ColumnSide ---@field folds? differ.FoldRange[] --- the hunk-staging capability the git frontend supplies per source. the --- view keeps its diff frozen and marks staged hunks in place rather than re-reading --- git, so it tracks per-hunk state and calls `apply` to patch one hunk: `reverse` --- false stages, true unstages, `offset` shifts past already-staged hunks before it. --- `initial` is every hunk's opening state (an unstaged diff opens unstaged, a staged --- one opens staged). `apply` patches one hunk and returns ok; `refresh` repaints the --- panel counts and is called once after a single toggle or a whole S/U batch +-- the hunk-staging capability the git frontend supplies per source. the view keeps its +-- diff frozen and reads staged state from `marks`, which the source keeps current; +-- `apply` stages one hunk (`reverse` unstages it) and returns ok, and `refresh` +-- repaints the panel counts once after a single toggle or a whole S/U batch. -- `apply` and `revert` are independently optional, and each one's absence gates its own --- keys rather than the whole capability. the git frontend supplies both wherever it --- stages at all: a deleted file stages wholesale like a new one, and reverts by --- restoring the file rather than removing it. --- `revert` throws a hunk away instead of moving it between index and worktree, and its --- absence is what gates the key off a source that can't do it. `offset` shifts its --- index-side apply past hunks unstaged before it; its worktree apply needs none, since --- a revert re-sources the model. `revert_label` names the consequence for a file whose +-- keys rather than the whole capability. `revert` throws a hunk away instead of moving +-- it between index and worktree. `revert_label` names the consequence for a file whose -- whole content is one hunk, where reverting isn't a partial act: the frontend knows -- what it will do to the file, the view words the question ---@class differ.view.Staging ----@field initial "staged"|"unstaged" +---@field initial? differ.model.HunkState -- a whole-file source: where it starts -- `whole_file` stages as a unit: the keys act on the file, and its diff shares one -- staged-state slot ---@field whole_file? boolean -- `apply` takes a nil hunk on a whole-file source, which ignores it ----@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, offset: integer, reverse: boolean): boolean ----@field revert? fun(model: differ.DiffModel, hunk: differ.Hunk, offset: integer): boolean +-- the second return says the op refused because this hunk can't move on its own, as +-- against a failure: the source has named the view that can take it whole +---@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean, boolean|nil +---@field revert? fun(model: differ.DiffModel, idx: integer): boolean +---@field refuses_revert? fun(model: differ.DiffModel, idx: integer): boolean -- said why, before X's prompt ---@field revert_label? string -- e.g. "deletes the file" +---@field no_revert? string -- why X does nothing here, in place of the default refusal ---@field refresh fun() ----@field settle? fun(): boolean -- post-op: re-target the view when this pair emptied +---@field marks? differ.model.Marks -- union source: which lines the index holds, kept current by the source +---@field hidden? boolean -- union source: some staged content isn't on screen +---@field hidden_in? integer[] -- the hunks marked `!`: they touch staged content the whole change can't show +---@field toggle_local? fun() -- a partly staged file: swap between the whole change and its local view +---@field no_local? string -- why dw does nothing here, in place of the default +---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit +---@field badge? string -- winbar tag naming a view that isn't the whole change +---@field unstage_hidden? fun(idx: integer): boolean -- u on a `!` hunk: drop the staged change it undoes +-- `unheld` says u on slot `idx`, or U when it's nil, drops staged content neither HEAD +-- nor the file holds, so the key confirms first +---@field unheld? fun(model: differ.DiffModel, idx: integer|nil): boolean +---@field confirm_stage? string -- s and S ask this first, `%s` the path: they drop content too +-- `drop_scope` names the lines u on `!` hunk `idx` takes back, when wider than the hunk +---@field drop_scope? fun(idx: integer): string|nil +-- `set_all` runs S / U on the whole file at once, and says whether the index moved +-- the second return says the op refused (a stale drawing, an unreadable index), as +-- against there being nothing to move +---@field set_all? fun(model: differ.DiffModel, staged: boolean): boolean, boolean|nil ---@class differ.View ---@field columns differ.ViewColumn[] @@ -103,9 +118,10 @@ local armed_view = nil ---@field keymaps table ---@field can_stage boolean -- session-level: bind s/u (worktree-status panels) ---@field staging differ.view.Staging|nil -- per-source capability (nil off-side) ----@field staged_hunks table -- hunk index -> staged, for marking +---@field commit_preview fun()|nil -- session-level: ds flips the panel's listing ---@field fold_memory table -- diff_key -> its open folds when last left ----@field on_edit_unstage fun(path: string)|nil -- frontend hook: unstage + re-source for edit-in-review +---@field marks differ.model.Marks -- which lines the index holds; hunk state rolls up from it +---@field stuck table> -- hunks a key refused to move, by the state it wanted ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) ---@field on_rerender fun()|nil -- session hook after a re-render, to re-apply overlays (pr threads) ---@field on_cursor fun()|nil -- session hook on cursor move in a diff window (pr thread cursor-expand) @@ -131,7 +147,7 @@ View.__index = View ---@field keymaps? table ---@field staging? differ.view.Staging ---@field can_stage? boolean ----@field on_edit_unstage? fun(path: string) +---@field commit_preview? fun() -- session-level: ds flips the panel's listing ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -161,14 +177,16 @@ function View.new(model, opts) ), can_stage = opts.can_stage or false, staging = opts.staging, - on_edit_unstage = opts.on_edit_unstage, + -- the session's own listing flip, so ds reads the same either side of the diff + commit_preview = opts.commit_preview, -- session-supplied maps the generic diff surface doesn't own (pr unviewed nav) extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, on_cursor = opts.on_cursor, on_repurpose = opts.on_repurpose, - staged_hunks = {}, fold_memory = {}, + marks = { old = {}, new = {} }, + stuck = { staged = {}, unstaged = {} }, id = next_id(), _suppress_close = false, _closing = false, @@ -178,18 +196,27 @@ function View.new(model, opts) return self end --- seed staged state for the current source: a staged diff (HEAD↔index) opens with --- everything staged, an unstaged diff (index↔worktree) with nothing +-- seed staged state for the current source. a hunk-level source hands over its marks +-- and keeps them current itself; a whole-file one starts from `initial` function View:_init_staged() - self.staged_hunks = {} - if not (self.staging and self.staging.initial == "staged") then + self:_forget_stuck() + local staging = self.staging + if staging and staging.marks then + self.marks = staging.marks return end - for i = 1, self:_slot_count() do - self.staged_hunks[i] = true + self.marks = { old = {}, new = {} } + if staging and self:_whole_file() then + self.marks.whole = staging.initial end end +-- drop the record of hunks a key has refused to move. any op moves what the index +-- holds, so what was unreachable before it may not be after +function View:_forget_stuck() + self.stuck = { staged = {}, unstaged = {} } +end + -- a stable, file-shaped buffer name so the statusline/winbar shows the file path -- instead of `[Scratch]`. the `differ://` scheme keeps it distinct from the -- real file (a bare relative name would resolve to the same absolute path and @@ -304,6 +331,7 @@ function View:rerender(opts) paint.apply(bufnr, ns, col) syntax.apply(bufnr, col, self.model) statuscolumn.set(bufnr, statuscolumn.format(col)) + statuscolumn.set_no_eol(bufnr, col.map) self.columns[i] = { bufnr = bufnr, winid = existing and existing.winid or nil, @@ -338,11 +366,23 @@ function View:_paint_staged() if not self.can_stage then return end + local hidden = {} + for _, h in ipairs(self.staging and self.staging.hidden_in or {}) do + hidden[h] = true + end for _, col in ipairs(self.columns) do + local opening, seen = {}, {} + for i, line in ipairs(col.map.lines) do + local h = line.hunk + if h and hidden[h] and not seen[h] then + seen[h], opening[i] = true, true + end + end + statuscolumn.set_hidden(col.bufnr, opening) vim.api.nvim_buf_clear_namespace(col.bufnr, staged_ns, 0, -1) local staged_lines = {} for i, line in ipairs(col.map.lines) do - if line.hunk and self.staged_hunks[self:_slot(line.hunk)] then + if self:_line_staged(line) then local row = i - 1 -- char-level fill with hl_eol, not line_hl_group: a line_hl_group covers -- the text but loses the past-EOL tail to the diff bg's own hl_eol (it @@ -432,10 +472,7 @@ function View:_paint_cursorline() -- a staged line is recoloured above the live word spans (210/215); lift the cursor -- tint over that so the focused line still lights up in its kind. on a normal line -- stay under the word spans (200) so changed words show through under the cursor - local staged = self.can_stage - and line - and line.hunk - and self.staged_hunks[self:_slot(line.hunk)] + local staged = self.can_stage and line ~= nil and self:_line_staged(line) vim.api.nvim_buf_set_extmark(col.bufnr, cursor_ns, row, 0, { end_row = row + 1, end_col = 0, @@ -445,22 +482,6 @@ function View:_paint_cursorline() }) end --- the net line-count delta of the staged hunks before `idx`: the frozen view's --- line numbers are from open time, but git applies against the live index, where --- each already-staged earlier hunk has shifted positions by its added/removed lines ----@param idx integer ----@return integer -function View:_stage_offset(idx) - local off = 0 - for j = 1, idx - 1 do - if self.staged_hunks[j] then - local h = self.model.hunks[j] - off = off + (h.new_count - h.old_count) - end - end - return off -end - -- open folds by the rail lines at their ends, with the texts those line numbers refer to ---@class differ.view.OpenedFolds ---@field old_text? string @@ -644,6 +665,27 @@ function View:toggle_layout() self:set_layout(self.layout == "stacked" and "split" or "stacked") end +-- dw: swap a partly staged file between its whole change (HEAD↔worktree) and what +-- changed since staging (index↔worktree) +-- ds: the same flip the panel's key does. the session owns the listing, so the view +-- only asks for it +function View:toggle_commit_preview() + if not self.commit_preview then + return vim.notify("differ: no commit preview here", vim.log.levels.INFO) + end + self.commit_preview() +end + +function View:toggle_local() + local toggle = self.staging and self.staging.toggle_local + if not toggle then + local why = self.staging and self.staging.no_local + or "only a partly staged file has a local view" + return vim.notify("differ: " .. why, vim.log.levels.WARN) + end + toggle() +end + -- set the per-view context line count (math.huge = whole file). same column -- count, so no relayout, content/map/gutter/highlights refresh in place ---@param n number @@ -800,6 +842,14 @@ function View:_setup_window(winid, bufnr) bind(bufnr, km.unstage_all, function() self:unstage_all() end, "differ: unstage all hunks") + bind(bufnr, km.toggle_local, function() + self:toggle_local() + end, "differ: toggle the local view") + if self.commit_preview then + bind(bufnr, km.commit_preview, function() + self:toggle_commit_preview() + end, "differ: toggle the commit preview") + end -- hunk-level here vs the panel's file-level discard, and destructive either -- way, so it confirms rather than acting straight off the key bind(bufnr, km.discard, function() @@ -821,6 +871,10 @@ end ---@return boolean moved function View:step_file(direction, wrap) local panel = require("differ.panel").current() + -- stepping off a file the s walk stopped on leaves it: the walk doesn't come back + if panel and self:_can_stage_hunk() and self:_review_done(false) then + panel:accept_review(false) + end if panel and panel:is_open() then return panel:goto_file(direction, true, wrap) -- keep focus in the diff window end @@ -886,6 +940,18 @@ function View:show_help() if self.staging and self.staging.revert then rows[#rows + 1] = { fmt(km.discard), "revert hunk (confirm)" } end + if self.staging and self.staging.toggle_local then + local what = self.staging.badge == "LOCAL" and "back to the whole change" + or "changes since staging" + rows[#rows + 1] = { fmt(km.toggle_local), what } + end + if self.commit_preview then + local what = "commit preview: staged changes only" + if self.staging and self.staging.badge == "STAGED" then + what = "back to every change" + end + rows[#rows + 1] = { fmt(km.commit_preview), what } + end for _, m in ipairs(self.extra_keymaps or {}) do rows[#rows + 1] = { fmt(m.spec), m.desc } end @@ -961,12 +1027,23 @@ function View:goto_hunk(direction, opts) end -- a hunk filter for the review scans: `staged` picks the side, so false matches every --- hunk still to stage and true every one still to unstage +-- hunk with something left to stage and true every one with something left to unstage. +-- a partial hunk matches both; one the key has already refused to move matches neither, +-- so the walk reads the file as done rather than circling the hunk it can't take. a `!` +-- hunk u can drop counts as something left to unstage ---@param staged boolean ---@return fun(hunk: integer): boolean function View:_review_filter(staged) + local done = staged and "unstaged" or "staged" + local refused = self.stuck[done] return function(hunk) - return (self.staged_hunks[self:_slot(hunk)] or false) == staged + if refused[hunk] then + return false + end + if staged and self:_drops_hidden(hunk) then + return true + end + return self:_hunk_state(hunk) ~= done end end @@ -1124,14 +1201,56 @@ function View:_slot_count() return #self.model.hunks end --- the slot holding hunk `h`'s staged state ----@param h integer ----@return integer -function View:_slot(h) +-- whether a rail line's content is already in the index +---@param line differ.RailLine +---@return boolean +function View:_line_staged(line) + if not line.hunk then + return false + end if self:_whole_file() then - return 1 + return self.marks.whole == "staged" + end + if line.kind == "old" then + return self.marks.old[line.old] or false + end + if line.kind == "new" then + return self.marks.new[line.new] or false + end + return false +end + +-- a slot's staged state. only a union source has a middle: a hunk git merged out of a +-- change staged and another made after it holds some lines the index has and some it +-- doesn't, and s and u both have work to do on it +---@param idx integer +---@return differ.model.HunkState +function View:_hunk_state(idx) + if self:_whole_file() then + return self.marks.whole or "unstaged" + end + local h = self.model.hunks[idx] + if not h then + return "unstaged" + end + return require("differ.model.marks").state(self.marks, h) +end + +-- how many of the file's hunks the index holds, and how many it holds part of. nil off +-- a hunk-staging source, which has nothing to tally +---@return { staged: integer, partial: integer, total: integer }|nil +function View:hunk_tally() + if not self:_can_stage_hunk() or self:_whole_file() then + return nil + end + local counts = { staged = 0, partial = 0, total = #self.model.hunks } + for i = 1, counts.total do + local state = self:_hunk_state(i) + if state == "staged" or state == "partial" then + counts[state] = counts[state] + 1 + end end - return h + return counts end -- the slot the staging keys act on: slot 1 for a whole-file source, backed by a hunk @@ -1144,24 +1263,28 @@ function View:_target_index() return self:_hunk_index_under_cursor() end --- patch hunk `idx` to `want_staged` in the index from the frozen hunk model (never --- buffer text), shifted past the hunks staged before it, and mark it. no panel --- refresh / repaint, so callers can batch. returns whether it changed +-- move hunk `idx` to `want_staged` in the index from the hunk model (never buffer +-- text), and mark it. no panel refresh / repaint, so callers can batch. returns +-- whether it changed, and whether the source refused because this hunk can't move on +-- its own (as against a failure, which leaves the cursor where it is) ---@param idx integer ---@param want_staged boolean ----@return boolean +---@return boolean changed, boolean|nil stuck function View:_apply_hunk(idx, want_staged) local apply = self.staging and self.staging.apply - if not apply or (self.staged_hunks[idx] or false) == want_staged then + local want = want_staged and "staged" or "unstaged" + if not apply or self:_hunk_state(idx) == want then return false end - local offset = self:_stage_offset(idx) - -- reverse unstages: we patch away a change currently in the index - if apply(self.model, self.model.hunks[idx], offset, not want_staged) then - self.staged_hunks[idx] = want_staged - return true + -- reverse unstages: the change leaves the index + local changed, stuck = apply(self.model, self.model.hunks[idx], not want_staged) + if not changed then + return false, stuck end - return false + if self:_whole_file() then + self.marks.whole = want + end + return true end -- s: stage the hunk under the cursor, or advance if there's nothing to stage here. @@ -1173,13 +1296,23 @@ function View:stage_hunk() return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end local idx = self:_target_index() - if idx and not (self.staged_hunks[idx] or false) then + if idx and self:_hunk_state(idx) ~= "staged" then + if not self:_stage_confirmed() then + return + end self:_toggle_hunk(true) else self:_step_review("next") end end +-- whether s / S may go ahead where staging drops content too +---@return boolean +function View:_stage_confirmed() + local prompt = self.staging and self.staging.confirm_stage + return not prompt or self:_confirmed(prompt:format(self.model.path)) +end + -- u: the mirror of s. unstage the staged hunk under the cursor, or retreat to the -- previous hunk left to unstage, so repeated u walks the change set backward function View:unstage_hunk() @@ -1187,23 +1320,159 @@ function View:unstage_hunk() return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end local idx = self:_target_index() - if idx and (self.staged_hunks[idx] or false) then + if idx and self:_hunk_state(idx) ~= "unstaged" then + if self:_drops_unheld(idx) and not self:_confirmed(self:_drop_prompt(idx)) then + return + end self:_toggle_hunk(false) + elseif idx and self:_drops_hidden(idx) then + self:_drop_hidden(idx) else self:_step_review("prev") end end +-- ask `prompt` before an op that drops content, and whether to go ahead. the prompt +-- drains scheduled callbacks, so the model is checked again after it +---@param prompt string +---@return boolean +function View:_confirmed(prompt) + local asked_on = self.model + if vim.fn.confirm(prompt, "&Yes\n&No", 2) ~= 1 then + return false + end + if self.model ~= asked_on then + vim.notify( + "differ: the diff changed while the prompt was up; nothing was changed", + vim.log.levels.WARN + ) + return false + end + return true +end + +-- the confirm for dropping the staged change in slot `idx`, or in the whole file +---@param idx integer|nil +---@return string +function View:_drop_prompt(idx) + local scope = idx and self.staging.drop_scope and self.staging.drop_scope(idx) + if scope then + return ("Unstage %s in %s? The staged change will be lost."):format(scope, self.model.path) + end + if idx and not self:_whole_file() then + return ("Unstage hunk %d/%d in %s? The staged change will be lost."):format( + idx, + #self.model.hunks, + self.model.path + ) + end + return ("Unstage %s? The staged change will be lost."):format(self.model.path) +end + +-- u on a `!` hunk drops staged content no other side holds, so it confirms like X does +---@param idx integer +function View:_drop_hidden(idx) + if self:_confirmed(self:_drop_prompt(idx)) then + self.staging.unstage_hidden(idx) + end +end + +-- whether u on slot `idx`, or U when it's nil, needs a confirm before it runs +---@param idx integer|nil +---@return boolean +function View:_drops_unheld(idx) + local unheld = self.staging and self.staging.unheld + return unheld ~= nil and unheld(self.model, idx) +end + +-- whether u on unmarked hunk `idx` drops staged content the whole change can't show +---@param idx integer +---@return boolean +function View:_drops_hidden(idx) + if not (self.staging and self.staging.unstage_hidden) then + return false + end + return vim.tbl_contains(self.staging.hidden_in or {}, idx) +end + -- hand the file-level half of the review walk to the panel: the next file with work --- left on the `staged` pair, cycling past the list ends. not View:step_file, which also +-- left on the `staged` pair, cycling past the list ends. s stops instead on a file whose +-- local view has something left, until dw finishes it or ]f leaves it. not View:step_file, which also -- serves ]f / [f and file history; the review flow needs a worktree-status panel (it's -- the only source that stages) and the state-aware step only that panel can make ---@param direction "next"|"prev" ---@param staged boolean ----@return boolean moved +---@return boolean handled -- moved, or stopped on this file function View:_step_review_file(direction, staged) local panel = require("differ.panel").current() - return panel ~= nil and panel:step_review(direction, staged, true) + if not panel then + return false + end + local st = self.staging + local offers_local = st ~= nil and st.toggle_local ~= nil and st.badge ~= "LOCAL" + if not staged and offers_local and panel:has_local_work() then + local key = require("differ.ui.help").fmt(self.keymaps.toggle_local) + local msg = ("differ: %s has local changes: %s shows them"):format(self.model.path, key) + vim.notify(msg, vim.log.levels.INFO) + return true + end + if self:_review_done(staged) then + panel:accept_review(staged) + end + return panel:step_review(direction, staged, true) +end + +-- whether no hunk here has anything left for the walk's key: stepping off leaves the +-- rest of the file as it is shown +---@param staged boolean +---@return boolean +function View:_review_done(staged) + local want = self:_review_filter(staged) + for i = 1, self:_slot_count() do + if want(i) then + return false + end + end + return true +end + +-- `names` joined while they fit LEFTOVER_WIDTH, the first always and whole, and the rest +-- as a count +---@param names string[] +---@return string +local function fit_names(names) + local shown, width = { names[1] }, vim.api.nvim_strwidth(names[1]) + for i = 2, #names do + width = width + 2 + vim.api.nvim_strwidth(names[i]) + if width > LEFTOVER_WIDTH then + break + end + shown[i] = names[i] + end + local out = table.concat(shown, ", ") + if #shown < #names then + out = ("%s +%d"):format(out, #names - #shown) + end + return out +end + +-- the walk's end: nothing left for the key. s names the rows it passed with changes +-- only the local view shows; what u leaves staged there (a move, a mode) isn't dw's +---@param staged boolean +function View:_walk_done(staged) + local msg = staged and "differ: nothing left to unstage" or "differ: nothing left to stage" + local panel = require("differ.panel").current() + local left = (panel and not staged) and panel:review_leftover() or {} + if #left > 0 then + local paths = {} + for i, e in ipairs(left) do + paths[i] = e.path + end + local verb = #left == 1 and "has" or "have" + local key = require("differ.ui.help").fmt(self.keymaps.toggle_local) + msg = ("%s (%s %s local changes: %s shows them)"):format(msg, fit_names(paths), verb, key) + end + vim.notify(msg, vim.log.levels.INFO) end -- the second tap of s / u: move to the next hunk that still has work left on it, @@ -1251,10 +1520,7 @@ function View:_step_review(direction) return end - vim.notify( - forward and "differ: no more hunks to stage" or "differ: no more hunks to unstage", - vim.log.levels.INFO - ) + self:_walk_done(staged) end -- toggle the staged state of the hunk under the cursor, marking it in place @@ -1269,32 +1535,49 @@ function View:_toggle_hunk(want_staged) if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end - if (self.staged_hunks[idx] or false) == want_staged then + -- a partial hunk is neither, and both keys have something to do on it: s takes the + -- lines the index is missing, u gives back the ones it holds + local state = self:_hunk_state(idx) + if state == (want_staged and "staged" or "unstaged") then return vim.notify( "differ: hunk already " .. (want_staged and "staged" or "unstaged"), vim.log.levels.INFO ) end - if self:_apply_hunk(idx, want_staged) then - self.staging.refresh() - -- that may have taken this pair's last hunk, in which case the session re-sources - -- the view onto the file's surviving pair and there's nothing here left to paint - if self.staging.settle and self.staging.settle() then - return - end - self:_paint_staged() - self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill + local changed, stuck = self:_apply_hunk(idx, want_staged) + if changed then + return self:_after_staging() + end + -- the source has said where this hunk's change can be taken whole, so carry the + -- walk on rather than parking on a hunk this key can never move + if stuck then + self.stuck[want_staged and "staged" or "unstaged"][idx] = true + self:_step_review(want_staged and "next" or "prev") end end +-- repaint after a staging op: the panel counts, the staged shade, and the winbar +-- tally, which a `%!` winbar only redraws on its own when the cursor moves +function View:_after_staging() + self:_forget_stuck() + self.staging.refresh() + self:_paint_staged() + self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill + vim.cmd.redrawstatus({ bang = true }) +end + -- S: stage every hunk in the file, or, when they're all staged already (nothing left to -- do here), move on to the next file with something to stage. the file-level echo of s -- advancing past the last hunk, minus its in-file wrap: S leaves no hunk behind to come -- back for function View:stage_all() - if not self:_toggle_all(true) and self:_can_stage_hunk() then + if self:_can_stage_hunk() and not self:_review_done(false) and not self:_stage_confirmed() then + return + end + local changed, refused = self:_toggle_all(true) + if not changed and not refused and self:_can_stage_hunk() then if not self:_step_review_file("next", false) then - vim.notify("differ: no more files to stage", vim.log.levels.INFO) + self:_walk_done(false) end end end @@ -1302,79 +1585,54 @@ end -- U: unstage every hunk, or, when none are staged (nothing to do), step back to the -- previous file with something staged, landing on its last staged hunk function View:unstage_all() - if not self:_toggle_all(false) and self:_can_stage_hunk() then + if self:_can_stage_hunk() and self:_drops_unheld(nil) then + local prompt = "Unstage all of %s? Some staged changes will be lost." + if not self:_confirmed(prompt:format(self.model.path)) then + return + end + end + local changed, refused = self:_toggle_all(false) + if not changed and not refused and self:_can_stage_hunk() then if self:_step_review_file("prev", true) then self:_focus_last_hunk() -- only when a previous file actually opened else - vim.notify("differ: no more files to unstage", vim.log.levels.INFO) + self:_walk_done(true) end end end --- stage / unstage every hunk. forward order keeps the running offset correct --- as the index shifts under each apply; the panel refreshes once after the batch. --- returns whether anything changed (false when already wholly in the target state), --- so S/U can fall through to file stepping +-- stage / unstage every hunk, through the source's whole-file op when it has one; the +-- panel refreshes once after the batch. returns whether anything changed (false when +-- already wholly in the target state), so S/U can fall through to file stepping, and +-- whether the source refused, which stays put instead ---@param want_staged boolean ----@return boolean changed +---@return boolean changed, boolean refused function View:_toggle_all(want_staged) if not self:_can_stage_hunk() then vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) - return false + return false, false end - local changed = false - for i = 1, self:_slot_count() do - if self:_apply_hunk(i, want_staged) then - changed = true + local changed, refused = false, false ---@type boolean, boolean|nil + if self.staging.set_all then + changed, refused = self.staging.set_all(self.model, want_staged) + else + local want = want_staged and "staged" or "unstaged" + for i = 1, self:_slot_count() do + if self:_hunk_state(i) ~= want then + local moved, stuck = self:_apply_hunk(i, want_staged) + if moved then + changed = true + elseif not stuck then + refused = true + break + end + end end end if changed then - self.staging.refresh() - -- S/U empty a pair outright, so this is where the follow usually fires - if not (self.staging.settle and self.staging.settle()) then - self:_paint_staged() - self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill - end + self:_after_staging() end - return changed -end - --- the index-side shift for a revert on a staged diff: unstaging a hunk swaps its new --- lines back for its old ones, so every hunk after it sits that many lines off the --- frozen model. only the index moves under an unstage, so this never applies to the --- worktree half of a revert ----@param idx integer ----@return integer -function View:_unstage_offset(idx) - local off = 0 - for j = 1, idx - 1 do - if not self.staged_hunks[j] then - local h = self.model.hunks[j] - off = off + (h.old_count - h.new_count) - end - end - return off -end - --- shift per-hunk staged marks down past a hunk that has just left the model. the --- rebuilt list normally loses exactly the reverted hunk and keeps the rest in order; --- if it didn't, the marks can't be mapped, so reseed them from the source instead of --- carrying wrong ones ----@param removed integer ----@param before integer -- hunk count before the revert -function View:_rekey_staged(removed, before) - if #self.model.hunks ~= before - 1 then - return self:_init_staged() - end - local out = {} - for i, staged in pairs(self.staged_hunks) do - if i < removed then - out[i] = staged - elseif i > removed then - out[i - 1] = staged - end - end - self.staged_hunks = out + return changed, refused == true end -- the new-side line to land on once `h` is reverted: the cursor's own line, shifted by @@ -1427,21 +1685,18 @@ function View:revert_hunk() end local revert = self.staging.revert if not revert then - return vim.notify("differ: this hunk can't be reverted", vim.log.levels.WARN) + local why = self.staging.no_revert or "this hunk can't be reverted" + return vim.notify("differ: " .. why, vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() + -- a whole-file source reverts as one unit, so it takes slot 1 even where the diff + -- draws a notice rather than hunks (a mode change, a bare rename, a submodule) + local idx = self:_target_index() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end - -- on an unstaged diff a marked hunk is already in the index, so reverting only the - -- worktree copy would leave the two differing the opposite way round - if self.model.new_rev == "WORKTREE" and self.staged_hunks[idx] then - return vim.notify( - "differ: hunk is staged; unstage it (u) before reverting", - vim.log.levels.WARN - ) + if self.staging.refuses_revert and self.staging.refuses_revert(self.model, idx) then + return end - -- a whole-file revert isn't partial, so it says so rather than counting hunks local label = self.staging.revert_label local prompt = label and ("Revert all of %s? This %s."):format(self.model.path, label) @@ -1461,14 +1716,12 @@ function View:revert_hunk() ) end - local offset = self.model.new_rev == "INDEX" and self:_unstage_offset(idx) or 0 -- a whole-file revert, or one taking the file's last hunk, leaves no diff at all local emptied = label ~= nil or #self.model.hunks == 1 local hunk = self.model.hunks[idx] -- captured before the model is replaced - local before = #self.model.hunks local focus = self:cursor_new_line() - if not revert(self.model, hunk, offset) then + if not revert(self.model, idx) then return end @@ -1481,14 +1734,14 @@ function View:revert_hunk() -- reports it; only speak up when the panel is still there but has nowhere to go local panel = require("differ.panel").current() if panel and not panel:open_nearest(true) then - vim.notify("differ: no changes left to show", vim.log.levels.INFO) + vim.notify("differ: no changes left", vim.log.levels.INFO) end return end local opened = self:_opened_folds() -- before rerender replaces col.folds + self:_forget_stuck() -- the hunks renumber around the one that just left self.model = require("differ.model.diff").revert_hunk(self.model, idx) - self:_rekey_staged(idx, before) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) self:_apply_folds(opened) -- stay where the reverted hunk was rather than being pulled to the next one @@ -1507,7 +1760,7 @@ end function View:jump_to_file() local root = self.model.root if not root then - vim.notify("differ: jump-to-file needs a file-backed source", vim.log.levels.WARN) + vim.notify("differ: this diff has no file to jump to", vim.log.levels.WARN) return end local abs = root .. "/" .. self.model.path @@ -1580,10 +1833,8 @@ end -- jump_to_file this never tears down the diff: you edit, `:w`, and the worktree -- watcher re-sources the diff in place (cursor held near its hunk). the projection -- buffer and line map are untouched (invariant 2); you edit the real file's own --- buffer, so LSP / treesitter / undo all work natively. a staged diff (index↔ side) --- can't be edited in place (you can't edit the index), so the file is unstaged first: --- the staged change returns to the worktree and the watcher re-sources to the now- --- unstaged diff, where the edit shows. git-correct; re-stage (s) when done +-- buffer, so LSP / treesitter / undo all work natively. a file not on disk (a +-- deletion) has nothing to edit, and _edit_target says so function View:edit_file() if not self:_editable_source() then return vim.notify( @@ -1591,23 +1842,13 @@ function View:edit_file() vim.log.levels.WARN ) end + if self.staging and self.staging.leave then + self.staging.leave() + end local t = self:_edit_target() if not t then return end - - -- staged diff: unstage the file and re-source to its unstaged index↔worktree view - -- so the edit lands on a diff that reflects it. driven explicitly (the watcher's - -- re-source is suppressed by the staging signature); falls back to an in-place - -- unstage if no frontend hook is wired - if self.model.new_rev == "INDEX" then - if self.on_edit_unstage then - self.on_edit_unstage(self.model.path) - elseif self:_can_stage_hunk() then - self:_toggle_all(false) - end - end - self:_open_edit_window(t.abs, t.target, t.tcol, t.anchor_win) end @@ -1697,7 +1938,7 @@ end function View:_edit_target() local root = self.model.root if not root then - vim.notify("differ: editing needs a file-backed source", vim.log.levels.WARN) + vim.notify("differ: this diff has no file to edit", vim.log.levels.WARN) return nil end local abs = root .. "/" .. self.model.path diff --git a/test/nvim/command_spec.lua b/test/nvim/command_spec.lua index e96dbad..18e3756 100644 --- a/test/nvim/command_spec.lua +++ b/test/nvim/command_spec.lua @@ -280,7 +280,7 @@ describe("command view controls from the panel", function() v:close() assert.is_true(on_tab) assert.are.equal("stacked", v.layout) - assert.matches("no diff view here", _G.notifs[#_G.notifs].msg) + assert.matches("no differ view open", _G.notifs[#_G.notifs].msg) end) end) diff --git a/test/nvim/diff_spec.lua b/test/nvim/diff_spec.lua index 863a3b9..35a4338 100644 --- a/test/nvim/diff_spec.lua +++ b/test/nvim/diff_spec.lua @@ -26,7 +26,8 @@ describe("model.diff.build", function() assert.are.same({ "B" }, m.hunks[1].new_lines) end) - it("handles add-only and missing trailing newline", function() + -- as in git: the unterminated `a` gives way to a terminated one + it("counts an unterminated last line as changed when lines follow it", function() local m = diff.build({ path = "x.lua", old_rev = "HEAD", @@ -35,6 +36,21 @@ describe("model.diff.build", function() new_text = "a\nb", }) assert.are.equal(1, #m.hunks) + assert.are.same({ "a" }, m.hunks[1].old_lines) + assert.are.same({ "a", "b" }, m.hunks[1].new_lines) + end) + + it("counts a final newline alone as a change to the last line", function() + local m = diff.build({ + path = "x.lua", + old_rev = "HEAD", + new_rev = "WORKTREE", + old_text = "a\nb", + new_text = "a\nb\n", + }) + assert.are.equal(1, #m.hunks) + assert.are.equal(2, m.hunks[1].old_start) + assert.are.same({ "b" }, m.hunks[1].old_lines) assert.are.same({ "b" }, m.hunks[1].new_lines) end) @@ -181,10 +197,9 @@ describe("model.diff.empty_reason", function() assert.are.equal("identical", reason("a\n", "a\n")) end) - it("names a final newline in either direction", function() - -- build ensures a trailing newline, so these reach zero hunks - assert.are.equal("eol_added", reason("a", "a\n")) - assert.are.equal("eol_removed", reason("a\n", "a")) + it("is nil for a final newline change, which is a hunk on the last line", function() + assert.is_nil(reason("a", "a\n")) + assert.is_nil(reason("a\n", "a")) end) it("is nil for binary content, which has its own placeholder", function() diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 0233c9c..93d83c2 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -36,6 +36,49 @@ local function fresh_repo() return root end +-- the panel row for `path`; `staged` narrows to a row with nothing unstaged (true) or +-- with something unstaged (false) +local function file_line(p, path, staged) + for i, m in ipairs(p.meta) do + if + m.kind == "file" + and m.entry.path == path + and (staged == nil or (m.entry.y == " ") == staged) + then + return i + end + end +end + +-- the diff view the panel drives. View.current keys off the focused buffer, and +-- panel keys leave focus on the panel, so it's looked up from the origin window +local function view_in_origin(p) + vim.api.nvim_set_current_win(p.origin_win) + return require("differ.view").current() +end + +-- the panel session on the repo just built, and the diff it opens on the origin file +local function open_panel() + git_src.panel({ rev = {}, open_first = true }) + local p = assert(require("differ.panel").current()) + return p, view_in_origin(p) +end + +-- run `fn` with vim.fn.confirm answering `choice` (1 yes, 2 no), returning the prompt +-- it was shown +---@return string|nil prompt +local function confirming(choice, fn) + local orig, prompt = vim.fn.confirm, nil + vim.fn.confirm = function(msg) + prompt = msg + return choice + end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) + return prompt +end + describe("git.read / changed_files", function() it("reads the committed version and the worktree version", function() local root = fresh_repo() @@ -75,6 +118,40 @@ describe("git.read / changed_files", function() assert.are.equal("a\r\nb\r\nc\r\n", git_src.read(head, root, "f.txt")) assert.are.equal("a\r\nB\r\nc\r\n", git_src.read(index, root, "f.txt")) end) + + -- git stores a symlink as the path it points at, not the contents behind it + it("reads a worktree symlink as its target path", function() + local root = fresh_repo() + write(root .. "/b.lua", "local y = 1\n") + vim.uv.fs_symlink("a.lua", root .. "/l") + git(root, "add", "b.lua", "l") + git(root, "commit", "-q", "-m", "link") + os.remove(root .. "/l") + vim.uv.fs_symlink("b.lua", root .. "/l") + + local wt = { kind = "worktree", label = "WORKTREE" } + local index = { kind = "index", label = "INDEX" } + assert.are.equal("a.lua", git_src.read(index, root, "l")) + assert.are.equal("b.lua", git_src.read(wt, root, "l")) + end) +end) + +-- a file name with glob characters, next to a file its glob matches +describe("git pathspecs", function() + it("unstages only the file named, not the files its glob matches", function() + local root = fresh_repo() + write(root .. "/a1.txt", "one\n") + write(root .. "/a[1].txt", "bracket\n") + git(root, "add", "a1.txt", "a[1].txt") + git(root, "commit", "-q", "-m", "two") + write(root .. "/a1.txt", "one staged\n") + write(root .. "/a[1].txt", "bracket staged\n") + git(root, "add", "--", "a1.txt", ":(literal)a[1].txt") + + assert.is_true(git_src.unstage(root, "a[1].txt")) + local staged = git(root, "diff", "--cached", "--name-only") + assert.are.equal("a1.txt\n", staged) + end) end) describe("git.read_stage", function() @@ -285,26 +362,25 @@ describe("git.read (worktree clean filter)", function() assert.are.equal("one\r\ntwo\r\n", git_src.read(wt, root, "c.txt")) end) - it("hunk staging stores the same blob a git add would (no CRLF leak)", function() + it("staging stores the same blob a git add would (no CRLF leak)", function() local root = fresh_repo() git(root, "config", "core.autocrlf", "input") - write(root .. "/a.lua", "local x = 1\r\nNEW LINE\r\nreturn x\r\n") + write(root .. "/c.txt", "one\r\ntwo\r\n") + git(root, "add", "c.txt") + git(root, "commit", "-q", "-m", "crlf") + write(root .. "/c.txt", "one\r\nNEW LINE\r\ntwo\r\n") - local source = { - old = { kind = "index", label = "INDEX" }, - new = { kind = "worktree", label = "WORKTREE" }, - } - local model = git_src.model(source, root, { path = "a.lua" }) - assert.are.equal(1, #model.hunks) -- the insert only, no phantom eol hunks - local patch = require("differ.git.patch") - local p = patch.hunk("a.lua", model.hunks[1], model.old_text, model.new_text, 0, false) - assert.is_true(git_src.apply_patch(root, p, false)) + vim.cmd.edit(root .. "/c.txt") + git_src.panel({ rev = {}, open_first = true }) + local p = require("differ.panel").current() + view_in_origin(p):stage_all() + p:close() -- the staged blob is byte-identical to what `git add` would have written - assert.are.equal("local x = 1\nNEW LINE\nreturn x\n", raw_indexed(root, "a.lua")) + assert.are.equal("one\nNEW LINE\ntwo\n", raw_indexed(root, "c.txt")) end) end) -describe("git.apply_patch (target)", function() +describe("git.revert_patch", function() local patch = require("differ.git.patch") local INDEX = { kind = "index", label = "INDEX" } local WT = { kind = "worktree", label = "WORKTREE" } @@ -329,33 +405,24 @@ describe("git.apply_patch (target)", function() return git_src.model({ old = INDEX, new = WT }, root, { path = "f.txt" }) end + -- the revert patches the file, so its hunk sits at the model's new-side lines ---@param model differ.DiffModel ---@param idx integer - ---@param reverse boolean - local function hunk_patch(model, idx, reverse) - return patch.hunk("f.txt", model.hunks[idx], model.old_text, model.new_text, 0, reverse) + local function hunk_patch(model, idx) + return patch.hunk("f.txt", model.hunks[idx], model.old_text, model.new_text, 0, "new") end - it("reverse-applies to the worktree and leaves the index alone", function() + it("reverses one hunk in the worktree and leaves the index alone", function() local root = two_hunk_repo() local model = unstaged_model(root) assert.are.equal(2, #model.hunks) - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree")) + assert.is_true(git_src.revert_patch(root, hunk_patch(model, 2))) -- only the second edit is undone; the first survives, as does the index assert.are.equal("a\nB\nc\nd\ne\n", git_src.read(WT, root, "f.txt")) assert.are.equal(BASE, git_src.read(INDEX, root, "f.txt")) end) - it("defaults to the index and leaves the worktree alone", function() - local root = two_hunk_repo() - local model = unstaged_model(root) - - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 1, false), false)) - assert.are.equal("a\nB\nc\nd\ne\n", git_src.read(INDEX, root, "f.txt")) - assert.are.equal(EDITED, git_src.read(WT, root, "f.txt")) - end) - -- the revert-on-a-staged-hunk path composes two calls, and the worktree one is -- located against content the user may have edited since; it must fail whole it("fails without writing when the worktree no longer holds the hunk's content", function() @@ -363,7 +430,7 @@ describe("git.apply_patch (target)", function() local model = unstaged_model(root) write(root .. "/f.txt", "a\nB\nc\nZZZ\ne\n") -- the D line was rewritten since - local ok, err = git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree") + local ok, err = git_src.revert_patch(root, hunk_patch(model, 2)) assert.is_false(ok) assert.is_not_nil(err) assert.are.equal("a\nB\nc\nZZZ\ne\n", git_src.read(WT, root, "f.txt")) @@ -376,27 +443,28 @@ describe("git.apply_patch (target)", function() local model = unstaged_model(root) write(root .. "/f.txt", "x1\nx2\nx3\na\nB\nc\nD\ne\n") -- D moves from line 4 to 7 - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree")) + assert.is_true(git_src.revert_patch(root, hunk_patch(model, 2))) assert.are.equal("x1\nx2\nx3\na\nB\nc\nd\ne\n", git_src.read(WT, root, "f.txt")) end) end) -- the missing-final-newline bug was never that the patch text looked wrong: it looked --- plausible, git apply reported success, and the index silently gained a joined line. +-- plausible, git apply reported success, and the file silently gained a joined line. -- the unit fixtures pin the text; only real git can pin the bytes it writes, and under -- `--unidiff-zero` a side with no lines leaves git trusting the header rather than --- failing loudly. every shape here round-trips, so an over-eager widening is caught --- too: unstaging has to land back on the original blob byte-for-byte -describe("git hunk staging round-trip (real git apply, EOF terminators)", function() +-- failing loudly. an over-eager widening is caught too: the revert has to land back on +-- the original file byte-for-byte +describe("git hunk revert (real git apply, EOF terminators)", function() local patch = require("differ.git.patch") local INDEX = { kind = "index", label = "INDEX" } local WT = { kind = "worktree", label = "WORKTREE" } - -- raw stdout, no text=true: that collapses the very terminators under test - local function indexed(root, path) - local res = vim.system({ "git", "cat-file", "blob", ":" .. path }, { cwd = root }):wait() - assert(res.code == 0, res.stderr) - return res.stdout + -- raw bytes, no readfile: that collapses the very terminators under test + local function on_disk(root, path) + local fd = assert(io.open(root .. "/" .. path, "rb")) + local data = fd:read("*a") + fd:close() + return data end -- committed, so the index starts equal to HEAD, with the worktree carrying the edit @@ -411,40 +479,35 @@ describe("git hunk staging round-trip (real git apply, EOF terminators)", functi return root end - -- stage the hunk, then unstage the same patch, checking the index blob at each - -- step. one hunk, so no offset: `reverse` only picks which way git reads it, which - -- is exactly what the staging keys do - local function round_trip(base, edited) + -- X's own path: reverse the hunk against the file, which has to put back the base + -- bytes exactly. one hunk, so no offset + local function revert_back(base, edited) local root = repo_with(base, edited) local model = git_src.model({ old = INDEX, new = WT }, root, { path = "f.txt" }) assert.are.equal(1, #model.hunks) - local p = patch.hunk("f.txt", model.hunks[1], model.old_text, model.new_text, 0, "old") - - local ok, err = git_src.apply_patch(root, p, false) - assert.is_true(ok, "stage failed: " .. tostring(err) .. "\n" .. p) - assert.are.equal(edited, indexed(root, "f.txt")) + local p = patch.hunk("f.txt", model.hunks[1], model.old_text, model.new_text, 0, "new") - ok, err = git_src.apply_patch(root, p, true) - assert.is_true(ok, "unstage failed: " .. tostring(err) .. "\n" .. p) - assert.are.equal(base, indexed(root, "f.txt")) + local ok, err = git_src.revert_patch(root, p) + assert.is_true(ok, "revert failed: " .. tostring(err) .. "\n" .. p) + assert.are.equal(base, on_disk(root, "f.txt")) end - it("appends past an unterminated last line, giving it its newline", function() - round_trip("a\nb", "a\nb\nc\n") + it("takes back an append that gave the last line its newline", function() + revert_back("a\nb", "a\nb\nc\n") end) - it("deletes the tail, leaving the new last line unterminated", function() - round_trip("a\nb\nc\n", "a\nb") + it("puts back a deleted tail, with the newline it had", function() + revert_back("a\nb\nc\n", "a\nb") end) - it("appends unterminated onto unterminated", function() - round_trip("a\nb", "a\nb\nc") + it("takes back an unterminated append onto an unterminated file", function() + revert_back("a\nb", "a\nb\nc") end) -- the control: an unterminated file whose hunk stops short of EOF must not widen, -- and must not quietly acquire a trailing newline on the way through it("leaves an unterminated file unterminated when the hunk is mid-file", function() - round_trip("a\nb", "a\nX\nb") + revert_back("a\nb", "a\nX\nb") end) end) @@ -510,9 +573,71 @@ describe("git.status_sections", function() end end assert.are.same({ - { path = "multi.lua", status = "?", additions = 3, deletions = 0, staged = false }, + { + path = "multi.lua", + status = "?", + additions = 3, + deletions = 0, + x = "?", + y = "?", + review = "unstaged", + }, }, untracked) end) + + -- every row as { section, path, status, kept } + local function rows_of(root) + local out = {} + for _, sec in ipairs(git_src.status_sections(root)) do + for _, e in ipairs(sec.entries) do + out[#out + 1] = { sec.title, e.path, e.status, e.kept or false } + end + end + return out + end + + -- `git rm --cached` leaves the file on disk, so porcelain lists the path twice + it("folds a kept deletion's copy on disk into its one Staged row", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + assert.are.same({ { "Staged", "a.lua", "D", true } }, rows_of(root)) + end) + + it("refuses to discard a kept deletion over its copy on disk", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + write(root .. "/a.lua", "mine\n") + local entry + for _, sec in ipairs(git_src.status_sections(root)) do + entry = entry or sec.entries[1] + end + _G.notifs = {} + assert.is_false(git_src.discard(root, entry)) + assert.is_truthy(_G.notifs[#_G.notifs].msg:find("overwrite a.lua on disk", 1, true)) + assert.are.equal("mine\n", table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") + end) + + it("lists a conflict once, in a Conflicts section ahead of the rest", function() + local root = fresh_repo() + git(root, "checkout", "-q", "-b", "side") + write(root .. "/a.lua", "side\n") + git(root, "commit", "-q", "-am", "side") + git(root, "checkout", "-q", "main") + write(root .. "/a.lua", "main\n") + git(root, "commit", "-q", "-am", "main") + -- conflicts, so the asserting helper can't run it; the identity is pinned inline + -- or a runner with no global gitconfig aborts the merge before it conflicts + vim.system( + { "git", "-c", "user.email=t@t", "-c", "user.name=t", "merge", "-q", "side" }, + { cwd = root } + ):wait() + write(root .. "/b.lua", "b\n") + + assert.are.same({ + { "Conflicts", "a.lua", "U", false }, + { "Untracked", "b.lua", "?", false }, + }, rows_of(root)) + end) end) describe("git.open_file", function() @@ -540,17 +665,6 @@ describe(":Differ panel", function() -- 1-based line of the file row for `path` (optionally pinned to a staged/unstaged -- section), located via the panel's meta so tests don't hardcode header offsets - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or m.entry.staged == staged) - then - return i - end - end - end -- the View driven by the panel, read without moving focus (which the focus-steal -- assertions below depend on): it lives in the origin window @@ -649,8 +763,7 @@ describe(":Differ panel", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua modified, unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local winbar = require("differ.ui.winbar") vim.g.statusline_winid = p.winid vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) @@ -847,8 +960,7 @@ describe(":Differ panel", function() write(root .. "/keep.lua", "kept\n") -- a survivor the diff should land on vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal("a.lua", view_at(p).model.path) git(root, "checkout", "HEAD", "--", "a.lua") -- only the shown file goes clean @@ -872,8 +984,7 @@ describe(":Differ panel", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal(1, #view_at(p).model.hunks) write(root .. "/a.lua", "local x = 2\nreturn x\nlocal y = 3\n") -- edited outside @@ -1021,32 +1132,57 @@ describe(":Differ panel", function() it("diffs a staged entry HEAD↔index and an unstaged entry index↔worktree", function() local root = fresh_repo() - -- stage one version of a.lua, then edit further in the worktree - write(root .. "/a.lua", "local x = 2\nreturn x\n") + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/a.lua", "local x = 2\nreturn x\n") -- staged outright git(root, "add", "a.lua") - write(root .. "/a.lua", "local x = 3\nreturn x\n") + write(root .. "/b.lua", "two\n") -- unstaged only vim.cmd.edit(root .. "/a.lua") git_src.panel({}) local p = Panel.current() - -- p:select returns focus to the panel, so look the View up via the origin - -- window's buffer (View.current keys off the focused buffer) - local function view_in_origin() - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end - -- a.lua is "MM": it appears in both the Staged and Unstaged sections vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", true), 0 }) p:select() - local v = view_in_origin() + local v = view_in_origin(p) assert.are.equal(V1, v.model.old_text) -- HEAD assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- index + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) + p:select() + v = view_in_origin(p) + assert.are.equal("one\n", v.model.old_text) -- index + assert.are.equal("two\n", v.model.new_text) -- worktree + p:close() + end) + + -- a file modified in the index and again in the worktree takes one row, not two, + -- and that row diffs HEAD↔worktree so the whole change reads at once + it("gives an MM file one Partial row diffing HEAD↔worktree", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 2\nreturn x + 1\n") + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({}) + local p = Panel.current() + assert.is_nil(file_line(p, "a.lua", true)) -- no Staged row + local rows = 0 + for _, m in ipairs(p.meta) do + if m.kind == "file" and m.entry.path == "a.lua" then + rows = rows + 1 + assert.are.equal("MM", m.entry.x .. m.entry.y) + end + end + assert.are.equal(1, rows) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", false), 0 }) p:select() - v = view_in_origin() - assert.are.equal("local x = 2\nreturn x\n", v.model.old_text) -- index - assert.are.equal("local x = 3\nreturn x\n", v.model.new_text) -- worktree + vim.api.nvim_set_current_win(p.origin_win) + local v = require("differ.view").current() + assert.are.equal(V1, v.model.old_text) -- HEAD + assert.are.equal("local x = 2\nreturn x + 1\n", v.model.new_text) -- worktree p:close() end) end) @@ -1061,23 +1197,12 @@ describe(":Differ panel staging (slice C)", function() if m.kind == "file" and m.entry.path == path - and (staged == nil or m.entry.staged == staged) + and (staged == nil or (m.entry.y == " ") == staged) then return m.entry end end end - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or m.entry.staged == staged) - then - return i - end - end - end local function keymaps(p) local lhs = {} for _, m in ipairs(vim.api.nvim_buf_get_keymap(p.bufnr, "n")) do @@ -1138,161 +1263,529 @@ describe(":Differ panel staging (slice C)", function() p:close() end) - it("discards a tracked file back to HEAD (after confirm)", function() + -- run `fn` with vim.fn.confirm answering `choice`, returning every prompt shown + local function prompts_of(choice, fn) + local orig, shown = vim.fn.confirm, {} + vim.fn.confirm = function(msg) + shown[#shown + 1] = msg + return choice + end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) + return shown + end + + it("u on a row whose staged blob is held nowhere else asks first", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") - vim.cmd.edit(root .. "/a.lua") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") -- MD: the index alone holds the edit + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") git_src.panel({}) local p = Panel.current() - vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local staged = git(root, "rev-parse", ":a.lua") - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("unstage") + end) + assert.are.equal(1, #shown) + assert.matches("Unstage a.lua%?", shown[1]) + assert.are.equal(staged, git(root, "rev-parse", ":a.lua")) -- declined: kept - assert.is_nil(entry_of(p, "a.lua")) -- no longer a change - assert.are.equal(V1, table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") - -- the file's own buffer is reloaded too, not left showing the discarded edit - local filebuf = vim.fn.bufnr(root .. "/a.lua") - assert.are.equal( - V1, - table.concat(vim.api.nvim_buf_get_lines(filebuf, 0, -1, false), "\n") .. "\n" - ) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + prompts_of(1, function() + p:stage_op("unstage") + end) + assert.are.equal(" D a.lua\n", (git(root, "status", "--porcelain=v1", "--", "a.lua"))) p:close() end) - it("discards an untracked file by deleting it", function() + it("u on a partly staged row asks only when the file lost staged lines", function() local root = fresh_repo() - write(root .. "/u.lua", "untracked\n") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 2\nreturn x\nprint(x)\n") -- keeps the staged line vim.cmd.edit(root .. "/a.lua") git_src.panel({}) local p = Panel.current() - vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "u.lua"), 0 }) - - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + assert.are.same( + {}, + prompts_of(2, function() + p:stage_op("unstage") + end) + ) + assert.are.equal(" M a.lua\n", (git(root, "status", "--porcelain=v1", "--", "a.lua"))) - assert.are.equal(0, vim.fn.filereadable(root .. "/u.lua")) + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 3\nreturn x\n") -- overwrites the staged line + p:refresh() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("unstage") + end) + assert.are.equal(1, #shown) + assert.are.equal("MM a.lua\n", (git(root, "status", "--porcelain=v1", "--", "a.lua"))) p:close() end) - -- confirm() drains scheduled callbacks while it blocks, so the entry's status can - -- move under the prompt. an untracked file staged from outside is the sharp case: - -- the "?" branch deletes it while the index keeps the add - it("refuses to discard a file whose status moved under the prompt", function() + it("u on a directory asks once, naming only the files that lose content", function() local root = fresh_repo() - write(root .. "/u.lua", "untracked\n") + vim.fn.mkdir(root .. "/src", "p") + write(root .. "/src/gone.lua", "g\n") + write(root .. "/src/kept.lua", "k\n") + git(root, "add", "src") + git(root, "commit", "-q", "-m", "src") + write(root .. "/src/gone.lua", "g2\n") + write(root .. "/src/kept.lua", "k2\n") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "-A") + os.remove(root .. "/src/gone.lua") -- MD + write(root .. "/src/kept.lua", "k2\nmore\n") -- MM, staged line still on disk + write(root .. "/a.lua", "local x = 2\nreturn x\nmore\n") vim.cmd.edit(root .. "/a.lua") git_src.panel({}) local p = Panel.current() - vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "u.lua"), 0 }) + local before = git(root, "ls-files", "-s") - _G.notifs = {} - local orig = vim.fn.confirm - vim.fn.confirm = function() - git(root, "add", "u.lua") -- "?" -> a staged add, from outside differ - return 1 - end - p:discard() - vim.fn.confirm = orig + vim.api.nvim_win_set_cursor(p.winid, { assert(dir_line(p, "src")), 0 }) + local shown = prompts_of(2, function() + p:stage_op("unstage") + end) + assert.are.equal(1, #shown) + assert.are.equal( + "Unstage these files? Staged changes in src/gone.lua will be lost.", + shown[1] + ) + assert.is_nil(shown[1]:find("kept", 1, true)) + assert.are.equal(before, git(root, "ls-files", "-s")) -- declined: nothing moved - assert.are.equal(1, vim.fn.filereadable(root .. "/u.lua")) - -- and the index still has the add, so the two can't disagree - assert.are.equal("untracked\n", git(root, "show", ":u.lua")) - assert.is_truthy(_G.notifs[#_G.notifs].msg:find("discard skipped: u.lua", 1, true)) + vim.api.nvim_win_set_cursor(p.winid, { assert(dir_line(p, "src")), 0 }) + prompts_of(1, function() + p:stage_op("unstage") + end) + local status = git(root, "status", "--porcelain=v1") + assert.matches(" D src/gone.lua", status, 1, true) + assert.matches(" M src/kept.lua", status, 1, true) + assert.matches("MM a.lua", status, 1, true) -- outside the directory: untouched p:close() end) - it("stages and unstages every file under a directory row", function() + it("U asks once before dropping staged content nothing else holds", function() local root = fresh_repo() - -- two files under src/, plus a sibling at the root so src/ stays a foldable - -- dir row (a sole common prefix 2+ deep would be stripped to a subtitle) - vim.fn.mkdir(root .. "/src", "p") - write(root .. "/src/a.lua", "a\n") - write(root .. "/src/b.lua", "b\n") - write(root .. "/top.lua", "t\n") - vim.cmd.edit(root .. "/top.lua") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + write(root .. "/b.lua", "b\n") + git(root, "add", "-A") + os.remove(root .. "/a.lua") + vim.cmd.edit(root .. "/b.lua") git_src.panel({}) local p = Panel.current() - assert.is_not_nil(entry_of(p, "src/a.lua", false)) - assert.is_not_nil(entry_of(p, "src/b.lua", false)) + local before = git(root, "ls-files", "-s") - vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) - p:stage_op("stage") - assert.is_not_nil(entry_of(p, "src/a.lua", true)) -- both files staged - assert.is_not_nil(entry_of(p, "src/b.lua", true)) - assert.is_nil(entry_of(p, "top.lua", true)) -- the sibling is untouched + local shown = prompts_of(2, function() + p:stage_op("unstage_all") + end) + assert.are.equal(1, #shown) + assert.are.equal("Unstage these files? Staged changes in a.lua will be lost.", shown[1]) + assert.are.equal(before, git(root, "ls-files", "-s")) - vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) - p:stage_op("unstage") - assert.is_not_nil(entry_of(p, "src/a.lua", false)) -- both back to unstaged - assert.is_not_nil(entry_of(p, "src/b.lua", false)) + prompts_of(1, function() + p:stage_op("unstage_all") + end) + local status = git(root, "status", "--porcelain=v1") + assert.matches(" D a.lua", status, 1, true) + assert.matches("?? b.lua", status, 1, true) p:close() end) - it("unstages a whole section from its header (deep prefix stripped, no dir row)", function() + it("s on an add deleted on disk asks before dropping the staged add", function() local root = fresh_repo() - -- every staged file shares a 2+-level prefix, so the tree strips it to a - -- header subtitle and emits no dir row: the header is the only group target - vim.fn.mkdir(root .. "/lua/panel", "p") - write(root .. "/lua/panel/init.lua", "i\n") - write(root .. "/lua/panel/render.lua", "r\n") - git(root, "add", "lua/panel/init.lua", "lua/panel/render.lua") - write(root .. "/loose.lua", "l\n") -- an unstaged file so the panel isn't all-staged - vim.cmd.edit(root .. "/loose.lua") + write(root .. "/c.lua", "c\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") -- AD: the index alone holds the add + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") git_src.panel({}) local p = Panel.current() - assert.is_nil(dir_line(p, "lua/panel")) -- confirm: prefix stripped, no dir row - assert.is_not_nil(entry_of(p, "lua/panel/init.lua", true)) + local staged = git(root, "rev-parse", ":c.lua") - vim.api.nvim_win_set_cursor(p.winid, { header_line(p, "Staged"), 0 }) - p:stage_op("unstage") - assert.is_nil(entry_of(p, "lua/panel/init.lua", true)) -- whole section unstaged - assert.is_nil(entry_of(p, "lua/panel/render.lua", true)) - assert.is_not_nil(entry_of(p, "lua/panel/init.lua", false)) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "c.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("stage") + end) + assert.are.same({ "Stage the deletion of c.lua? The staged file will be lost." }, shown) + assert.are.equal(staged, git(root, "rev-parse", ":c.lua")) -- declined: kept + + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "c.lua"), 0 }) + prompts_of(1, function() + p:stage_op("stage") + end) + assert.are.equal("", (git(root, "status", "--porcelain=v1", "--", "c.lua"))) p:close() end) - it("discards every file under a directory row (after confirm)", function() + it("S asks once before staging an add deleted on disk", function() local root = fresh_repo() - vim.fn.mkdir(root .. "/src", "p") - write(root .. "/src/a.lua", "a\n") - write(root .. "/src/b.lua", "b\n") - write(root .. "/top.lua", "t\n") - vim.cmd.edit(root .. "/top.lua") + write(root .. "/c.lua", "c\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") git_src.panel({}) local p = Panel.current() - vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) + local before = git(root, "ls-files", "-s") - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + local shown = prompts_of(2, function() + p:stage_op("stage_all") + end) + assert.are.equal(1, #shown) + assert.are.equal("Stage these files? Staged changes in c.lua will be lost.", shown[1]) + assert.are.equal(before, git(root, "ls-files", "-s")) -- declined: nothing moved - assert.are.equal(0, vim.fn.filereadable(root .. "/src/a.lua")) -- both deleted - assert.are.equal(0, vim.fn.filereadable(root .. "/src/b.lua")) - assert.are.equal(1, vim.fn.filereadable(root .. "/top.lua")) -- sibling kept + prompts_of(1, function() + p:stage_op("stage_all") + end) + local status = git(root, "status", "--porcelain=v1") + assert.is_nil(status:find("c.lua", 1, true)) + assert.matches("M a.lua", status, 1, true) p:close() end) - it("binds staging keys for the worktree source", function() + it("s on an edit deleted on disk asks only when nothing else holds the edit", function() local root = fresh_repo() + write(root .. "/b.lua", "b\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "update-index", "--chmod=+x", "b.lua") + os.remove(root .. "/b.lua") -- MD whose blob HEAD still has write(root .. "/a.lua", "local x = 2\nreturn x\n") - vim.cmd.edit(root .. "/a.lua") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") -- MD with a new blob + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") git_src.panel({}) local p = Panel.current() - local lhs = keymaps(p) - for _, k in ipairs({ "s", "u", "S", "U", "X", "R" }) do - assert.is_true(lhs[k]) + + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua"), 0 }) + assert.are.same( + {}, + prompts_of(2, function() + p:stage_op("stage") + end) + ) + assert.are.equal("D b.lua\n", (git(root, "status", "--porcelain=v1", "--", "b.lua"))) + + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("stage") + end) + assert.are.same({ "Stage the deletion of a.lua? The staged file will be lost." }, shown) + assert.are.equal("MD a.lua\n", (git(root, "status", "--porcelain=v1", "--", "a.lua"))) + p:close() + end) + + it("s on a rename deleted on disk asks only when the rename carried an edit", function() + local root = fresh_repo() + git(root, "mv", "a.lua", "b.lua") + os.remove(root .. "/b.lua") -- RD whose blob HEAD still has at a.lua + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua"), 0 }) + assert.are.same( + {}, + prompts_of(2, function() + p:stage_op("stage") + end) + ) + p:close() + + root = fresh_repo() + git(root, "mv", "a.lua", "b.lua") + write(root .. "/b.lua", V1 .. "more\n") + git(root, "add", "b.lua") + os.remove(root .. "/b.lua") -- RD with an edit only the index holds + assert.matches("^RD", git(root, "status", "--porcelain=v1")) + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") + git_src.panel({}) + p = Panel.current() + local before = git(root, "ls-files", "-s") + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("stage") + end) + assert.are.same({ "Stage the deletion of b.lua? The staged file will be lost." }, shown) + assert.are.equal(before, git(root, "ls-files", "-s")) + p:close() + end) + + it("s on a typechange deleted on disk asks first", function() + local root = fresh_repo() + write(root .. "/z.lua", "z\n") + local link = vim.trim(git(root, "hash-object", "-w", "z.lua")) + git(root, "update-index", "--cacheinfo", "120000," .. link .. ",a.lua") + os.remove(root .. "/a.lua") -- TD: the index alone holds the symlink + assert.matches("TD a.lua", git(root, "status", "--porcelain=v1"), 1, true) + vim.cmd.edit(root .. "/z.lua") + git_src.panel({}) + local p = Panel.current() + local before = git(root, "ls-files", "-s") + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local shown = prompts_of(2, function() + p:stage_op("stage") + end) + assert.are.same({ "Stage the deletion of a.lua? The staged file will be lost." }, shown) + assert.are.equal(before, git(root, "ls-files", "-s")) + p:close() + end) + + it("s on a plain unstaged row stages without asking", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + assert.are.same( + {}, + prompts_of(2, function() + p:stage_op("stage") + end) + ) + assert.are.equal("M a.lua\n", (git(root, "status", "--porcelain=v1", "--", "a.lua"))) + p:close() + end) + + it("discards a tracked file back to HEAD (after confirm)", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + + confirming(1, function() + p:discard() + end) + + assert.is_nil(entry_of(p, "a.lua")) -- no longer a change + assert.are.equal(V1, table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") + -- the file's own buffer is reloaded too, not left showing the discarded edit + local filebuf = vim.fn.bufnr(root .. "/a.lua") + assert.are.equal( + V1, + table.concat(vim.api.nvim_buf_get_lines(filebuf, 0, -1, false), "\n") .. "\n" + ) + p:close() + end) + + it("leaves a deletion kept on disk staged on s", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + p:stage_op("stage") + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("D a.lua\n?? a.lua\n", status) + end) + + it("does nothing on s over a staged deletion", function() + local root = fresh_repo() + git(root, "rm", "-q", "a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + _G.notifs = {} + p:stage_op("stage") + local failed = vim.tbl_filter(function(n) + return n.msg:find("failed", 1, true) ~= nil + end, _G.notifs) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.are.same({}, failed) + assert.are.equal("D a.lua\n", status) + end) + + it("brings a staged edit whose file was deleted back as staged on X", function() + local root = fresh_repo() + write(root .. "/a.lua", "edited\n") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + local prompt = confirming(1, function() + p:discard() + end) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.are.equal("Restore a.lua as staged?", prompt) + assert.are.equal("M a.lua\n", status) + assert.are.same({ "edited" }, vim.fn.readfile(root .. "/a.lua")) + end) + + it("refuses X on a submodule rather than claiming to move its checkout", function() + local root = fresh_repo() + local sub = root .. "/sub" + vim.fn.mkdir(sub, "p") + git(sub, "init", "-q") + git(sub, "commit", "-q", "--allow-empty", "-m", "one") + git(root, "add", "sub") + git(root, "commit", "-q", "-m", "sub") + git(sub, "commit", "-q", "--allow-empty", "-m", "two") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "sub"), 0 }) + _G.notifs = {} + confirming(1, function() + p:discard() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal( + "differ: discard skipped: X can't move the checkout inside the submodule sub", + said + ) + assert.are.equal(" M sub\n", status) + end) + + it("discards an untracked file by deleting it", function() + local root = fresh_repo() + write(root .. "/u.lua", "untracked\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "u.lua"), 0 }) + + confirming(1, function() + p:discard() + end) + + assert.are.equal(0, vim.fn.filereadable(root .. "/u.lua")) + p:close() + end) + + -- confirm() drains scheduled callbacks while it blocks, so the entry's status can + -- move under the prompt. an untracked file staged from outside is the sharp case: + -- the "?" branch deletes it while the index keeps the add + it("refuses to discard a file whose status moved under the prompt", function() + local root = fresh_repo() + write(root .. "/u.lua", "untracked\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "u.lua"), 0 }) + + _G.notifs = {} + local orig = vim.fn.confirm + vim.fn.confirm = function() + git(root, "add", "u.lua") -- "?" -> a staged add, from outside differ + return 1 + end + p:discard() + vim.fn.confirm = orig + + assert.are.equal(1, vim.fn.filereadable(root .. "/u.lua")) + -- and the index still has the add, so the two can't disagree + assert.are.equal("untracked\n", git(root, "show", ":u.lua")) + assert.is_truthy(_G.notifs[#_G.notifs].msg:find("discard skipped: u.lua", 1, true)) + p:close() + end) + + it("stages and unstages every file under a directory row", function() + local root = fresh_repo() + -- two files under src/, plus a sibling at the root so src/ stays a foldable + -- dir row (a sole common prefix 2+ deep would be stripped to a subtitle) + vim.fn.mkdir(root .. "/src", "p") + write(root .. "/src/a.lua", "a\n") + write(root .. "/src/b.lua", "b\n") + write(root .. "/top.lua", "t\n") + vim.cmd.edit(root .. "/top.lua") + git_src.panel({}) + local p = Panel.current() + assert.is_not_nil(entry_of(p, "src/a.lua", false)) + assert.is_not_nil(entry_of(p, "src/b.lua", false)) + + vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) + p:stage_op("stage") + assert.is_not_nil(entry_of(p, "src/a.lua", true)) -- both files staged + assert.is_not_nil(entry_of(p, "src/b.lua", true)) + assert.is_nil(entry_of(p, "top.lua", true)) -- the sibling is untouched + + vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) + p:stage_op("unstage") + assert.is_not_nil(entry_of(p, "src/a.lua", false)) -- both back to unstaged + assert.is_not_nil(entry_of(p, "src/b.lua", false)) + p:close() + end) + + it("unstages a whole section from its header (deep prefix stripped, no dir row)", function() + local root = fresh_repo() + -- every staged file shares a 2+-level prefix, so the tree strips it to a + -- header subtitle and emits no dir row: the header is the only group target + vim.fn.mkdir(root .. "/lua/panel", "p") + write(root .. "/lua/panel/init.lua", "i\n") + write(root .. "/lua/panel/render.lua", "r\n") + git(root, "add", "lua/panel/init.lua", "lua/panel/render.lua") + write(root .. "/loose.lua", "l\n") -- an unstaged file so the panel isn't all-staged + vim.cmd.edit(root .. "/loose.lua") + git_src.panel({}) + local p = Panel.current() + assert.is_nil(dir_line(p, "lua/panel")) -- confirm: prefix stripped, no dir row + assert.is_not_nil(entry_of(p, "lua/panel/init.lua", true)) + + vim.api.nvim_win_set_cursor(p.winid, { header_line(p, "Staged"), 0 }) + p:stage_op("unstage") + assert.is_nil(entry_of(p, "lua/panel/init.lua", true)) -- whole section unstaged + assert.is_nil(entry_of(p, "lua/panel/render.lua", true)) + assert.is_not_nil(entry_of(p, "lua/panel/init.lua", false)) + p:close() + end) + + it("discards every file under a directory row (after confirm)", function() + local root = fresh_repo() + vim.fn.mkdir(root .. "/src", "p") + write(root .. "/src/a.lua", "a\n") + write(root .. "/src/b.lua", "b\n") + write(root .. "/top.lua", "t\n") + vim.cmd.edit(root .. "/top.lua") + git_src.panel({}) + local p = Panel.current() + vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) + + confirming(1, function() + p:discard() + end) + + assert.are.equal(0, vim.fn.filereadable(root .. "/src/a.lua")) -- both deleted + assert.are.equal(0, vim.fn.filereadable(root .. "/src/b.lua")) + assert.are.equal(1, vim.fn.filereadable(root .. "/top.lua")) -- sibling kept + p:close() + end) + + it("binds staging keys for the worktree source", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = Panel.current() + local lhs = keymaps(p) + for _, k in ipairs({ "s", "u", "S", "U", "X", "R" }) do + assert.is_true(lhs[k]) end p:close() end) @@ -1328,11 +1821,15 @@ end) describe(":Differ diff hunk staging", function() local Panel = require("differ.panel") - -- p:select returns focus to the panel, so the View lives in the origin window - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end + -- a spec that aborts before its p:close() would otherwise leak its panel into every + -- spec after it, and the count would name them all rather than the one that broke + after_each(function() + local open = Panel.current() + if open then + open:close() + end + end) + -- the first buffer row of the diff showing `text` local function row_at(v, text) for row, l in ipairs(vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) do @@ -1358,6 +1855,19 @@ describe(":Differ diff hunk staging", function() local function worktree(root, path) return table.concat(vim.fn.readfile(root .. "/" .. path), "\n") .. "\n" end + local function committed(root, path) + return git(root, "show", "HEAD:" .. path) + end + -- an "RM": a.lua renamed to b.lua and edited in the index, then edited again in the + -- worktree + ---@return string path -- the new name + local function rename_and_edit(root, staged_text, work_text) + git(root, "mv", "a.lua", "b.lua") + write(root .. "/b.lua", staged_text) + git(root, "add", "b.lua") + write(root .. "/b.lua", work_text) + return "b.lua" + end -- the buffer line hunk `n` starts on, in the view's primary column local function hunk_line(v, n) for lnum, line in ipairs(v.columns[1].map.lines) do @@ -1381,10 +1891,8 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two far-apart edits vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- index↔worktree opens unstaged + local p, v = open_panel() + assert.are.equal("unstaged", v:_hunk_state(1)) -- index↔worktree opens unstaged assert.are.equal(2, #v.model.hunks) -- two distinct hunks (lines 1 and 8) local before = vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false) @@ -1398,8 +1906,8 @@ describe(":Differ diff hunk staging", function() -- the diff didn't vanish or re-source: same hunks, same buffer, hunk 1 marked assert.are.equal(2, #v.model.hunks) assert.are.same(before, vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) - assert.is_true(v.staged_hunks[1]) - assert.is_nil(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -1417,13 +1925,11 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- a second file to stage from the panel vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() -- mark hunk 1 in place, leaving hunk 2 unstaged - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) -- stage a *different* file whole, from the sidebar vim.api.nvim_set_current_win(p.winid) @@ -1436,8 +1942,8 @@ describe(":Differ diff hunk staging", function() p.on_external_change() assert.are.equal(2, #v.model.hunks) -- still frozen on the same two hunks - assert.is_true(v.staged_hunks[1]) -- and hunk 1 is still marked staged - assert.is_nil(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) -- and hunk 1 is still marked staged + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -1447,21 +1953,19 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") -- whole change staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) -- a staged (HEAD↔index) diff - assert.is_true(v.staged_hunks[1]) -- opens marked staged + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) -- a staged (HEAD↔index) diff + assert.are.equal("staged", v:_hunk_state(1)) -- opens marked staged assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:unstage_hunk() assert.are.equal(V1, indexed(root, "a.lua")) -- index reverted to HEAD - assert.is_false(v.staged_hunks[1]) -- now marked unstaged, still visible + assert.are.equal("unstaged", v:_hunk_state(1)) -- now marked unstaged, still visible v:stage_hunk() -- u then s on the same hunk: the mark toggles back assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) p:close() end) @@ -1472,12 +1976,10 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two far-apart edits vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) -- s/u on a hunk already in the target state re-enter the review flow instead -- (stage_hunk/unstage_hunk step past it), so _toggle_hunk itself is called @@ -1486,15 +1988,15 @@ describe(":Differ diff hunk staging", function() v:_toggle_hunk(true) assert.are.equal("differ: hunk already staged", _G.notifs[1].msg) assert.are.equal(vim.log.levels.INFO, _G.notifs[1].level) - assert.is_true(v.staged_hunks[1]) -- state untouched + assert.are.equal("staged", v:_hunk_state(1)) -- state untouched v:_toggle_hunk(false) -- actually unstage it, so the mirror check below is real - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) _G.notifs = {} v:_toggle_hunk(false) assert.are.equal("differ: hunk already unstaged", _G.notifs[1].msg) assert.are.equal(vim.log.levels.INFO, _G.notifs[1].level) - assert.is_false(v.staged_hunks[1]) -- state untouched + assert.are.equal("unstaged", v:_hunk_state(1)) -- state untouched p:close() end) @@ -1503,20 +2005,18 @@ describe(":Differ diff hunk staging", function() write(root .. "/new.lua", "alpha\nbeta\n") -- untracked, the only change vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_not_nil(v.staging) -- a new file now offers (whole-file) staging - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) -- empty<->content is a single hunk vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() assert.are.equal("alpha\nbeta\n", indexed(root, "new.lua")) -- whole file staged - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) v:unstage_hunk() -- u back: leaves the index, untracked again - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) local porc = vim.system( { "git", "status", "--porcelain", "--", "new.lua" }, { cwd = root, text = true } @@ -1531,9 +2031,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- outside differ: edit the viewed file further, and change git state (stage a @@ -1550,25 +2048,19 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- unstaging a whole-file change splits it off the pair the review was on, and the - -- re-source then has only the other pair to land on. `s` there acts on that entry - -- instead, so the swap can't be silent - it("says when an external change re-sources the diff onto the other pair", function() + -- a change made outside differ can turn the shown file into a different kind of + -- change, and the keys then act on that one instead, so the swap can't be silent + it("says when an external change re-sources the diff onto another status", function() local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- a staged add: the only pair this file has - vim.cmd.edit(root .. "/new.lua") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {} }) - local p = Panel.current() - assert.is_true(p:focus_file("new.lua")) - p:select(true) - local v = view_in_origin(p) - assert.are.equal("INDEX", v.model.new_rev) -- HEAD↔index, the staged pair - v:unstage_hunk() -- the staged pair is gone; the file is untracked now + local p = open_panel() + assert.are.equal("M", p:current_entry().status) _G.notifs = {} - write(root .. "/other.lua", "unrelated\n") -- something else moves the signature + os.remove(root .. "/a.lua") -- a modification becomes a deletion + write(root .. "/other.lua", "unrelated\n") git(root, "add", "other.lua") p:reload() @@ -1579,7 +2071,7 @@ describe(":Differ diff hunk staging", function() end end assert.is_truthy(msg) - assert.is_truthy(msg:find("new.lua", 1, true)) + assert.is_truthy(msg:find("a.lua", 1, true)) p:close() end) @@ -1588,8 +2080,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() _G.notifs = {} write(root .. "/a.lua", "local x = 99\nreturn x\n") -- same file, same pair @@ -1603,22 +2094,22 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- the panel's own staging keys move the pair the diff was built from, so the window - -- has to follow. the diff window's own s/u keep their frozen model instead, which is - -- what lets the staged marks survive a stage + -- the panel's own staging keys move the index under the open diff, so the window + -- re-sources to pick up the marks it("re-sources the open diff when the panel's own keys stage the file", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - assert.are.equal("WORKTREE", view_in_origin(p).model.new_rev) -- the unstaged pair + local p = open_panel() + assert.are.equal("unstaged", view_in_origin(p):_hunk_state(1)) _G.notifs = {} p:stage_op("stage_all") -- S on the panel row, not in the diff - assert.are.equal("INDEX", view_in_origin(p).model.new_rev) -- followed to HEAD↔index + local v = view_in_origin(p) + assert.are.equal("WORKTREE", v.model.new_rev) -- still HEAD↔worktree + assert.are.equal("staged", v:_hunk_state(1)) -- differ's own doing, so it isn't reported the way an outside change is for _, n in ipairs(_G.notifs) do assert.is_nil(tostring(n.msg):find("changed outside differ", 1, true)) @@ -1635,227 +2126,2848 @@ describe(":Differ diff hunk staging", function() git(root, "reset", "-q", "HEAD") -- unstaged: a deletion plus an untracked file vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {} }) - local p = Panel.current() - assert.is_true(p:focus_file("old.lua")) - p:select(true) - assert.are.equal("old.lua", view_in_origin(p).model.path) + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("old.lua")) + p:select(true) + assert.are.equal("old.lua", view_in_origin(p).model.path) + + p:stage_op("stage_all") -- both halves reach the index, and git pairs them + + -- old.lua has no entry left at all, so the window moves to the rename rather + -- than sitting on a deletion the change set no longer holds + assert.are.equal("new.lua", view_in_origin(p).model.path) + p:close() + end) + + it("opens on the current file, snapping to the hunk nearest the cursor", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- hunks at lines 1 and 10 + write(root .. "/m.lua", "new\n") + git(root, "add", "m.lua") -- a staged file that sorts ahead of a.lua + + vim.cmd.edit(root .. "/a.lua") + vim.api.nvim_win_set_cursor(0, { 10, 0 }) -- near the second hunk + + local p, v = open_panel() + + -- opened a.lua (the current file) though m.lua sorts first in the list + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n", v.model.new_text) + -- and snapped to the second hunk (nearest the cursor), not the first + local col = v.columns[#v.columns] + local row = vim.api.nvim_win_get_cursor(col.winid)[1] + assert.are.equal(2, col.map.lines[row].hunk) + p:close() + end) + + -- an "MM" file takes one row diffing HEAD↔worktree. the origin line is a worktree + -- line, and the union pair's new side is the worktree, so it lands exactly rather + -- than being read in index coordinates and snapping to a stray hunk + it("opens an MM file on its union pair, holding the exact origin line", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") -- staged: a hunk at line 1 + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- unstaged: a hunk at line 10 + + vim.cmd.edit(root .. "/a.lua") + vim.api.nvim_win_set_cursor(0, { 10, 2 }) -- on the unstaged change + + local p, v = open_panel() + + -- HEAD↔worktree, carrying both the staged hunk at line 1 and the unstaged one + assert.are.equal(committed(root, "a.lua"), v.model.old_text) + assert.are.equal(worktree(root, "a.lua"), v.model.new_text) + assert.are.equal(2, #v.model.hunks) + + -- line 10 is a changed line there, so it's held exactly (column included) + -- rather than falling back to the nearest hunk + local col = v.columns[#v.columns] + local row, ccol = unpack(vim.api.nvim_win_get_cursor(col.winid)) + assert.are.equal(10, col.map.lines[row].new) + assert.is_not_nil(col.map.lines[row].hunk) + assert.are.equal(2, ccol) + p:close() + end) + + -- a file staged outright has a worktree equal to its index, so its HEAD↔worktree + -- diff is the staged change, every hunk marked + it("opens a fully staged origin file with its hunks marked staged", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") -- staged, with nothing left in the worktree + vim.cmd.edit(root .. "/a.lua") + + local p, v = open_panel() + assert.are.equal("a.lua", v.model.path) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal(V1, v.model.old_text) + assert.are.equal(indexed(root, "a.lua"), v.model.new_text) + p:close() + end) + + -- staging a file's last unstaged hunk moves its row from Partial to Staged, so the + -- rows shift. restoring the panel cursor by line number then lands it on a + -- different file, and ]f / [f step from there + it("keeps the panel cursor on its file when the row leaves the section", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") + write(root .. "/z.lua", "z1\nz2\n") + git(root, "add", "z.lua") + git(root, "commit", "-q", "-am", "two files") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n") -- staged: a hunk at line 1 + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- unstaged: a hunk at line 8 + write(root .. "/z.lua", "z1x\nz2\n") -- a neighbour to slide into the vacated row + + vim.cmd.edit(root .. "/a.lua") + vim.api.nvim_win_set_cursor(0, { 8, 0 }) + + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + local before = p:current_entry() + assert.are.equal("a.lua", before.path) + assert.are.equal("M", before.y) + + -- the open landed on the line-8 hunk already; stage it, emptying the unstaged side + vim.api.nvim_set_current_win(p.origin_win) + local col = v.columns[#v.columns] + assert.is_not_nil(col.map.lines[vim.api.nvim_win_get_cursor(col.winid)[1]].hunk) + v:stage_hunk() + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) + + -- a.lua moved to the Staged section; the cursor follows the file there rather + -- than staying on the line z.lua just slid into + local after = p:current_entry() + assert.are.equal("a.lua", after.path) + assert.are.equal(" ", after.y) + -- and the selection ]f / [f step from tracks it too + assert.are.equal("a.lua", p.meta[p.selected_row].entry.path) + p:close() + end) + + -- staging a file's last unstaged hunk moves only the marks: HEAD↔worktree doesn't + -- change under staging, so the diff and the reviewer's line stay put + it("stays on the same diff when the last unstaged hunk is staged", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + local path = rename_and_edit( + root, + "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", -- staged: a hunk at line 1 + "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n" -- unstaged: a hunk at line 10 + ) + + vim.cmd.edit(root .. "/" .. path) + vim.api.nvim_win_set_cursor(0, { 10, 2 }) + + local p, v = open_panel() + assert.are.equal(2, #v.model.hunks) -- the staged line-1 change and the line-10 one + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + local model = v.model + + vim.api.nvim_set_current_win(p.origin_win) + local real_notify, said = vim.notify, {} + vim.notify = function(msg) + said[#said + 1] = msg + end + local ok, err = pcall(v.stage_hunk, v) + vim.notify = real_notify + assert.is_true(ok, tostring(err)) + + assert.are.same({}, said) + + -- the same diff, now with every hunk marked staged + assert.are.equal(model, v.model) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) + + -- and the cursor held line 10 rather than snapping to the first hunk + local col = v.columns[#v.columns] + local row, ccol = unpack(vim.api.nvim_win_get_cursor(col.winid)) + assert.are.equal(10, col.map.lines[row].new) + assert.are.equal(2, ccol) + p:close() + end) + + -- an "MM" file diffs HEAD↔worktree, so staging moves the marks and leaves the diff + -- alone: no pair to empty, nothing to follow, and s/u round-trip on the same buffer + it("stages and unstages an MM file's hunks without swapping the diff", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + + local buffer = vim.api.nvim_buf_get_lines(v.columns[#v.columns].bufnr, 0, -1, false) + assert.are.equal(2, #v.model.hunks) -- both edits, in one diff + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) -- all of it staged + -- the buffer never moved: only the marks did + assert.are.same(buffer, vim.api.nvim_buf_get_lines(col.bufnr, 0, -1, false)) + + v:unstage_hunk() + assert.are.equal("unstaged", v:_hunk_state(2)) + assert.are.equal("staged", v:_hunk_state(1)) -- the other hunk is untouched + assert.are.same(buffer, vim.api.nvim_buf_get_lines(col.bufnr, 0, -1, false)) + p:close() + end) + + local ACME_HEAD = [==[ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptrace" + "time" +) + +func main() { + now := time.Now() + fmt.Printf("starting up: %v\n", now.Format(time.UnixDate)) + + ctx := context.TODO() + + for i := range 5 { + startTime := time.Now() + c := http.Client{} + trace := httptrace.ClientTrace{ + DNSStart: func(di httptrace.DNSStartInfo) { + fmt.Printf("dns lookup started at %v with host: %s\n", time.Now().Format(time.UnixDate), di.Host) + }, + TLSHandshakeStart: func() { + fmt.Printf("tls handshake started at %v\n", time.Since(startTime)) + }, + ConnectDone: func(network, addr string, err error) { + if err != nil { + fmt.Printf("failed to connect: %s\n", err.Error()) + return + } + fmt.Printf("connected to network at address: %s\n", addr) + }, + } + traceCtx := httptrace.WithClientTrace(ctx, &trace) + req, err := http.NewRequestWithContext(traceCtx, http.MethodGet, "https://acme.example/download?bytes=42", nil) + if err != nil { + panic(err) + } + + _, err = c.Do(req) + if err != nil { + panic(err) + } + fmt.Printf("request %d took %v\n", i, time.Since(startTime)) + } + + fmt.Printf("total time lapsed: %s\n", time.Since(now).String()) +} +]==] + + local ACME_WORK = [==[ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptrace" + "slices" + "time" +) + +func main() { + now := time.Now() + times := []time.Duration{} + ctx := context.TODO() + url := "https://acme.example/download?bytes=1337" + + fmt.Printf("starting up: %v\n", now.Format(time.UnixDate)) + + for i := range 5 { + reqTime := ping(ctx, url, i) + fmt.Printf("request %d took %v\n", i, reqTime) + times = append(times, reqTime) + } + + slices.Sort(times) + + fmt.Printf("total time lapsed: %s\nmedian request: %v\n", time.Since(now).String(), median(times)) +} + +func ping(ctx context.Context, url string, iteration int) time.Duration { + now := time.Now() + c := http.Client{} + trace := httptrace.ClientTrace{ + DNSStart: func(di httptrace.DNSStartInfo) { + fmt.Printf("dns lookup started at %v with host: %s\n", time.Now().Format(time.UnixDate), di.Host) + }, + TLSHandshakeStart: func() { + fmt.Printf("tls handshake started at %v\n", time.Now().Format(time.UnixDate)) + }, + ConnectDone: func(network, addr string, err error) { + if err != nil { + fmt.Printf("failed to connect: %s\n", err.Error()) + return + } + fmt.Printf("connected to network at address: %s\n", addr) + }, + GotFirstResponseByte: func() { + fmt.Printf("response %d took %s to get first response byte\n", iteration, time.Since(now).String()) + }, + } + traceCtx := httptrace.WithClientTrace(ctx, &trace) + req, err := http.NewRequestWithContext(traceCtx, http.MethodGet, url, nil) + if err != nil { + panic(err) + } + + resp, err := c.Do(req) + if err != nil { + panic(err) + } + + io.Copy(io.Discard, resp.Body) + + defer resp.Body.Close() + + return time.Since(now) +} + +func median(input []time.Duration) time.Duration { + middle := len(input) / 2 + if len(input)%2 == 1 { + return input[middle] + } + + return (input[middle-1] + input[middle]) / 2 +} + +func getDownloadSpeed() { + // throughput = bytes / (t_end − t_first_byte) +} +]==] + + -- with hunks 1-4 staged, HEAD↔index keeps `ctx` in place and moves the Printf, where + -- HEAD↔worktree keeps the Printf and moves `ctx`: the index still holds whole hunks + it("stages and unstages hunk by hunk where HEAD↔index pairs lines differently", function() + local root = fresh_repo() + write(root .. "/a.go", ACME_HEAD) + git(root, "add", "a.go") + git(root, "commit", "-q", "-m", "ping") + write(root .. "/a.go", ACME_WORK) + + vim.cmd.edit(root .. "/a.go") + local p, v = open_panel() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local function press(n, op) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + v[op](v) + end + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + + local count = #v.model.hunks + for n = 1, 4 do + press(n, "stage_hunk") + end + local staged, hidden, index = states(), v.staging.hidden_in, indexed(root, "a.go") + press(4, "unstage_hunk") + local after4 = states() + press(3, "unstage_hunk") + local after3, index3 = states(), indexed(root, "a.go") + press(2, "unstage_hunk") + press(1, "unstage_hunk") + local final = indexed(root, "a.go") + p:close() + + local h, w = vim.split(ACME_HEAD, "\n"), vim.split(ACME_WORK, "\n") + local function joined(top, rest) + return table.concat(vim.list_extend(top, rest), "\n") + end + local S, U = "staged", "unstaged" + assert.are.equal(6, count) + assert.are.same({ S, S, S, S, U, U }, staged) + assert.is_nil(hidden) + assert.are.equal(joined(vim.list_slice(w, 1, 20), vim.list_slice(h, 17)), index) + assert.are.same({ S, S, S, U, U, U }, after4) + assert.are.same({ S, S, U, U, U, U }, after3) + assert.are.equal(joined(vim.list_slice(w, 1, 14), vim.list_slice(h, 13)), index3) + assert.are.equal(ACME_HEAD, final) + end) + + -- point `path`'s index entry at `content` without touching the worktree + local function index_as(root, path, content) + local blob = root .. "/.blob" + write(blob, content) + local sha = vim.trim(git(root, "hash-object", "-w", blob)) + os.remove(blob) + git(root, "update-index", "--cacheinfo", "100644," .. sha .. "," .. path) + end + -- a repo with a.lua committed as `head`, staged as `index` and on disk as `work`, and + -- the diff :Differ opens on it + local function staged_as(head, index, work) + local root = fresh_repo() + write(root .. "/a.lua", head) + git(root, "commit", "-q", "-am", "head") + index_as(root, "a.lua", index) + write(root .. "/a.lua", work) + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + return root, p, view_in_origin(p) + end + local function lines_of(t) + return table.concat(t, "\n") .. "\n" + end + + -- HEAD a b c unterminated, worktree A b c with its final newline: the ending is + -- hunk 2, on line c + it("stages and unstages a final newline as the last line's hunk", function() + local head = "a\nb\nc" + local root, p, v = staged_as(head, head, "A\nb\nc\n") + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local function press(n, op) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + v[op](v) + return states(), indexed(root, "a.lua") + end + local before = states() + local s1, i1 = press(1, "stage_hunk") + local s2, i2 = press(2, "stage_hunk") + local u2, i3 = press(2, "unstage_hunk") + local u1, i4 = press(1, "unstage_hunk") + local reverted = v.staging.revert(v.model, 2) + local fd = assert(io.open(root .. "/a.lua", "rb")) -- worktree() always adds a newline + local work, index = fd:read("*a"), indexed(root, "a.lua") + fd:close() + p:close() + + local S, U = "staged", "unstaged" + assert.are.same({ U, U }, before) + assert.are.same({ S, U }, s1) + assert.are.equal("A\nb\nc", i1) + assert.are.same({ S, S }, s2) + assert.are.equal("A\nb\nc\n", i2) + assert.are.same({ S, U }, u2) + assert.are.equal("A\nb\nc", i3) + assert.are.same({ U, U }, u1) + assert.are.equal(head, i4) + assert.is_true(reverted) + assert.are.equal("A\nb\nc", work) + assert.are.equal(head, index) + end) + + -- HEAD a b _ _ c d, worktree a b B _ d: the blank beside hunk 1 also fits hunk 2 + it("stages and reverts by hunk where a blank line fits either hunk", function() + local head = lines_of({ "a", "b", "", "", "c", "d" }) + local root, p, v = staged_as(head, head, lines_of({ "a", "b", "B", "", "d" })) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local before, hidden = states(), v.staging.hidden + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local staged, index = states(), indexed(root, "a.lua") + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + local unstaged = indexed(root, "a.lua") + local reverted = v.staging.revert(v.model, 1) + local after, work = + indexed(root, "a.lua"), table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") + p:close() + + local S, U = "staged", "unstaged" + assert.are.same({ U, U }, before) + assert.is_nil(hidden) + assert.are.same({ S, U }, staged) + assert.are.equal(lines_of({ "a", "b", "B", "", "", "c", "d" }), index) + assert.are.equal(head, unstaged) + assert.is_true(reverted) + assert.are.equal(head, after) + assert.are.equal("a\nb\n\nd", work) + end) + + -- hunk 3 staged without its `url` line and hunk 4 staged whole: u on 3 leaves 4 alone + it("unstages a partly staged hunk without reaching the whole one beside it", function() + local h, w = vim.split(ACME_HEAD, "\n"), vim.split(ACME_WORK, "\n") + local function joined(...) + local out = {} + for _, part in ipairs({ ... }) do + vim.list_extend(out, part) + end + return table.concat(out, "\n") + end + local index = + joined(vim.list_slice(w, 1, 16), vim.list_slice(w, 18, 20), vim.list_slice(h, 17)) + local root, p, v = staged_as(ACME_HEAD, index, ACME_WORK) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local before, hidden = states(), v.staging.hidden_in + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 3), 0 }) + v:unstage_hunk() + local after, written = states(), indexed(root, "a.lua") + p:close() + + local S, U, P = "staged", "unstaged", "partial" + assert.are.same({ S, S, P, S, U, U }, before) + assert.is_nil(hidden) + assert.are.same({ S, S, U, S, U, U }, after) + local want = + joined(vim.list_slice(w, 1, 14), vim.list_slice(h, 13, 14), vim.list_slice(h, 17)) + assert.are.equal(want, written) + end) + + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: the index's B C sit + -- between the two hunks, where neither u nor s on one hunk can reach them + local JOINED = { + head = lines_of({ "1", "2", "3", "4", "5", "6", "7" }), + index = lines_of({ "1", "A", "B", "C", "D", "E", "7" }), + work = lines_of({ "1", "A2", "3", "4", "D2", "E2", "7" }), + } + + it("U gives the index HEAD's text where hunk by hunk can't", function() + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + local prompt = confirming(1, function() + v:unstage_all() + end) + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.are.equal("Unstage all of a.lua? Some staged changes will be lost.", prompt) + assert.are.equal(JOINED.head, written) + assert.is_nil(hidden) + end) + + -- 2x staged and then edited again to 2y: the index's 2x is on neither side + it( + "u on a ! hunk in the whole change asks before dropping what only the index holds", + function() + local head, index = lines_of({ "1", "2", "3" }), lines_of({ "1", "2x", "3" }) + local root, p, v = staged_as(head, index, lines_of({ "1", "2y", "3" })) + local win = v.columns[#v.columns].winid + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 1), 0 }) + local prompt = confirming(2, function() + v:unstage_hunk() + end) + local kept = indexed(root, "a.lua") + confirming(1, function() + v:unstage_hunk() + end) + local dropped = indexed(root, "a.lua") + p:close() + assert.are.equal("Unstage hunk 1/1 in a.lua? The staged change will be lost.", prompt) + assert.are.equal(index, kept) + assert.are.equal(head, dropped) + end + ) + + -- 2x staged with +x, then -x and 2y on disk: the mode change doesn't hide 2x from u + it("u on a ! hunk asks too when the index also holds a mode change", function() + local root = fresh_repo() + write(root .. "/a.lua", lines_of({ "1", "2", "3" })) + git(root, "commit", "-q", "-am", "head") + local index = lines_of({ "1", "2x", "3" }) + write(root .. "/a.lua", index) + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") + write(root .. "/a.lua", lines_of({ "1", "2y", "3" })) + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local win = v.columns[#v.columns].winid + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 1), 0 }) + local prompt = confirming(2, function() + v:unstage_hunk() + end) + local kept = indexed(root, "a.lua") + local mode = git(root, "ls-files", "-s", "--", "a.lua"):match("^(%d+)") + p:close() + assert.are.equal("Unstage hunk 1/1 in a.lua? The staged change will be lost.", prompt) + assert.are.equal(index, kept) + assert.are.equal("100755", mode) + end) + + it("u on a staged edit whose file was deleted asks before dropping it", function() + local root = fresh_repo() + write(root .. "/a.lua", "edited\n") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + local p = open_panel() + assert.is_true(p:goto_path("a.lua", true)) + local prompt = confirming(1, function() + view_in_origin(p):unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.are.equal("Unstage a.lua? The staged change will be lost.", prompt) + assert.are.equal(" D a.lua\n", status) + end) + + it( + "u on a binary file staged and changed again asks before dropping the staged blob", + function() + local root = fresh_repo() + write(root .. "/b.dat", "\0one") + git(root, "add", "b.dat") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.dat", "\0two") + git(root, "add", "b.dat") + write(root .. "/b.dat", "\0three") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.dat", true)) + local prompt = confirming(2, function() + view_in_origin(p):unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "b.dat") + p:close() + assert.are.equal("Unstage b.dat? The staged change will be lost.", prompt) + assert.are.equal("MM b.dat\n", status) + end + ) + + it("S gives the index the worktree's text where hunk by hunk can't", function() + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + v:stage_all() + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.are.equal(JOINED.work, written) + assert.is_nil(hidden) + end) + + -- +x staged over HEAD's 644: no hunk shows the mode, and S and U still land the + -- index on the mode of the side they take + local function with_staged_mode(work_exec) + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + local sha = git(root, "rev-parse", ":0:a.lua"):gsub("%s+$", "") + git(root, "update-index", "--cacheinfo", "100755," .. sha .. ",a.lua") + if work_exec then + vim.fn.system({ "chmod", "+x", root .. "/a.lua" }) + end + return root, p, v + end + local function index_mode(root) + return git(root, "ls-files", "-s", "--", "a.lua"):match("^(%d+)") + end + + it("S takes the worktree's mode", function() + local root, p, v = with_staged_mode(false) + v:stage_all() + local mode = index_mode(root) + p:close() + assert.are.equal("100644", mode) + end) + + it("S keeps a mode the worktree has too", function() + local root, p, v = with_staged_mode(true) + v:stage_all() + local mode = index_mode(root) + p:close() + assert.are.equal("100755", mode) + end) + + it("U takes HEAD's mode", function() + local root, p, v = with_staged_mode(true) + confirming(1, function() + v:unstage_all() + end) + local mode = index_mode(root) + p:close() + assert.are.equal("100644", mode) + end) + + -- 5x staged and then put back to 5 on disk: no hunk shows it, U still takes it out + it("U drops staged content no hunk shows", function() + local ten = {} + for i = 1, 10 do + ten[i] = tostring(i) + end + local head = lines_of(ten) + ten[5] = "5x" + local index = lines_of(ten) + ten[5], ten[1] = "5", "1x" + local root, p, v = staged_as(head, index, lines_of(ten)) + local hidden_before = v.staging.hidden + confirming(1, function() + v:unstage_all() + end) + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.is_not_nil(hidden_before) + assert.are.equal(head, written) + assert.is_nil(hidden) + end) + + -- the file moves on disk after the diff is drawn and before the watcher re-reads it + for _, case in ipairs({ + { + what = "an edit inside a hunk", + edit = function(t) + t[10] = "10z" + end, + }, + { + what = "a line above every hunk", + edit = function(t) + table.insert(t, 1, "0") + end, + }, + }) do + it(("refuses s on a diff drawn before %s, then re-reads"):format(case.what), function() + local t = {} + for i = 1, 12 do + t[i] = tostring(i) + end + local head = lines_of(t) + t[3], t[10] = "3x", "10x" + local root, p, v = staged_as(head, head, lines_of(t)) + local drawn = v.model + case.edit(t) + write(root .. "/a.lua", lines_of(t)) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + _G.notifs = {} + v:stage_hunk() + local written = indexed(root, "a.lua") + local said = (_G.notifs[#_G.notifs] or {}).msg or "" + vim.wait(1000, function() + return v.model ~= drawn + end, 20) + local reread = v.model.new_text + p:close() + assert.are.equal(head, written) + assert.is_truthy(said:find("changed since the diff was drawn", 1, true)) + assert.are.equal(lines_of(t), reread) + end) + end + + -- a change staged and then edited again within a few lines merges into one + -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still + -- say which of it the index has + it("marks a merged hunk partial and stages the rest with s", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + + assert.are.equal(1, #v.model.hunks) + assert.are.equal("partial", v:_hunk_state(1)) + -- the staged half is line 3 on each side; the later edits are not + local col = v.columns[#v.columns] + local dimmed = {} + for _, line in ipairs(col.map.lines) do + if v:_line_staged(line) then + dimmed[#dimmed + 1] = line.kind == "old" and line.old or line.new + end + end + assert.are.same({ 3, 3 }, dimmed) + + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) + p:close() + end) + + it("unstages a partial hunk with u", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal("partial", v:_hunk_state(1)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + assert.are.equal("unstaged", v:_hunk_state(1)) + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + p:close() + end) + + -- a staged insertion sits after line 2, and the edit below it starts at line 3: the + -- one hunk they merge into holds both, so u and X reach the insertion too + ---@param root string + ---@return differ.Panel, differ.View + local function insert_then_edit_below(root) + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\nN\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\nN\n3x\n4\n5\n6\n7\n8\n9\n10\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + return p, v + end + + it("unstages an insertion staged just above the hunk's edit", function() + local root = fresh_repo() + local p, v = insert_then_edit_below(root) + assert.are.equal("partial", v:_hunk_state(1)) + v:unstage_hunk() + local state, index = v:_hunk_state(1), indexed(root, "a.lua") + p:close() + assert.are.equal("unstaged", state) + assert.are.equal(committed(root, "a.lua"), index) + end) + + it("reverts an insertion staged just above the hunk's edit from both sides", function() + local root = fresh_repo() + local p, v = insert_then_edit_below(root) + confirming(1, function() + v:revert_hunk() + end) + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + p:close() + assert.are.equal(committed(root, "a.lua"), work) + assert.are.equal(committed(root, "a.lua"), index) + end) + + -- the worktree deletes line 3, just above the staged 4x: index↔worktree holds the + -- deletion as an insertion point that sits before the union hunk's first line + it("stages a deletion just above a staged edit", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3\n4x\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n4x\n5\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal("partial", v:_hunk_state(1)) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local state, index = v:_hunk_state(1), indexed(root, "a.lua") + p:close() + assert.are.equal("staged", state) + assert.are.equal(worktree(root, "a.lua"), index) + end) + + -- the index rewrote 2..6 as A..E and the worktree put 3 and 4 back: both pairs hold + -- one hunk across what shows as two, and B C sit only in the index + ---@param root string + ---@return differ.Panel, differ.View + local function shared_change(root) + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n") + git(root, "commit", "-q", "-am", "seven lines") + write(root .. "/a.lua", "1\nA\nB\nC\nD\nE\n7\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\nA2\n3\n4\nD2\nE2\n7\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + _G.notifs = {} + return p, v + end + + for _, case in ipairs({ + { + key = "s", + op = "stage_hunk", + what = "this hunk can't be staged without hunk 2; stage it from dw", + }, + { + key = "u", + op = "unstage_hunk", + what = "this hunk can't be unstaged without hunk 2; unstage it from ds", + }, + { + key = "X", + op = "revert_hunk", + what = "this hunk can't be reverted without hunk 2", + }, + }) do + it(("refuses %s on a hunk sharing a change with the next"):format(case.key), function() + local root = fresh_repo() + local p, v = shared_change(root) + local before = { worktree(root, "a.lua"), indexed(root, "a.lua") } + local hunks = #v.model.hunks + local prompt = confirming(1, function() + v[case.op](v) + end) + local after = { worktree(root, "a.lua"), indexed(root, "a.lua") } + -- the first thing said, since s and u go on to step the walk and may + -- announce a wrap after the refusal + local said = (_G.notifs[1] or {}).msg or "" + p:close() + assert.are.equal(2, hunks) + assert.are.same(before, after) + assert.is_truthy(said:find(case.what, 1, true), said) + assert.is_nil(prompt) -- refused before anything is asked + end) + end + + -- the same refusal as above: s can't take hunk 1 without hunk 2, so it says where + -- both can be taken and carries the walk on rather than parking on hunk 1 + it("steps s off a hunk sharing a change with the next", function() + local root = fresh_repo() + local p, v = shared_change(root) + assert.are.equal(1, v:_hunk_index_under_cursor()) + v:stage_hunk() + assert.are_not.equal(1, v:_hunk_index_under_cursor()) + assert.is_true(v.stuck.staged[1]) + p:close() + end) + + -- the file is edited under the open view: X refuses before either side moves, so the + -- index keeps its half too rather than dropping it alone + it("X leaves the index alone when the file won't take the revert", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local edited = "1z\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n" + write(root .. "/a.lua", edited) + _G.notifs = {} + confirming(1, function() + v:revert_hunk() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + p:close() + assert.are.equal("differ: the file changed since the diff was drawn: re-reading", said) + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", index) + assert.are.equal(edited, work) + end) + + -- the glob a[1].txt matches the executable a1.txt, which sorts first + it("stages a hunk of a glob-named file keeping its own mode", function() + local root = fresh_repo() + local ten = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n" + write(root .. "/a1.txt", "one\n") + write(root .. "/a[1].txt", ten) + git(root, "add", "a1.txt", "a[1].txt") + git(root, "update-index", "--chmod=+x", "a1.txt") + git(root, "commit", "-q", "-m", "two") + write(root .. "/a[1].txt", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "--", ":(literal)a[1].txt") + write(root .. "/a[1].txt", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(vim.fn.fnameescape(root .. "/a[1].txt")) + local p, v = open_panel() + if v.model.path ~= "a[1].txt" then + p:close() + error("opened " .. v.model.path .. ", not the bracketed file") + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + p:close() + local listed = git(root, "--literal-pathspecs", "ls-files", "-s", "--", "a[1].txt") + assert.are.equal("100644", listed:match("^(%d+)")) + end) + + -- a partial hunk still holds something to unstage, so the backward walk stops on it + it("walks u back onto a partial hunk", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal("partial", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:unstage_hunk() -- nothing staged here, so it steps back + assert.are.equal(hunk_line(v, 1), vim.api.nvim_win_get_cursor(col.winid)[1]) + p:close() + end) + + -- a Partial row holds work in both directions, so the file step backwards (U with + -- nothing staged here) lands on it rather than passing it by + it("steps the unstage walk onto a Partial row", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-am", "ten lines and b") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + write(root .. "/b.lua", "two\n") -- unstaged only + + vim.cmd.edit(root .. "/b.lua") + local p = open_panel() + assert.are.equal("b.lua", view_in_origin(p).model.path) + + assert.is_true(p:step_review("prev", true, true)) + assert.are.equal("a.lua", view_in_origin(p).model.path) + p:close() + end) + + -- the winbar text over the diff column of `v` + local function winbar_of(v) + vim.g.statusline_winid = v.columns[#v.columns].winid + local text = require("differ.ui.winbar").diff() + vim.g.statusline_winid = nil + return text + end + + -- the gutter drawn beside buffer line `lnum` of the diff column of `v` + local function gutter_of(v, lnum) + local win = v.columns[#v.columns].winid + local fmt = vim.wo[win].statuscolumn + return vim.api.nvim_eval_statusline(fmt, { winid = win, use_statuscol_lnum = lnum }).str + end + + -- git's `\ No newline at end of file`, as a gutter glyph beside the line it belongs to + it("marks a line its side ends on without a newline in the gutter", function() + local root = fresh_repo() + write(root .. "/a.lua", "one\ntwo\n") + git(root, "commit", "-q", "-am", "two lines") + write(root .. "/a.lua", "one\ntwo") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local marked, plain = {}, gutter_of(v, 1) + for lnum, line in ipairs(v.columns[#v.columns].map.lines) do + if gutter_of(v, lnum):find("¬", 1, true) then + marked[#marked + 1] = line.kind + end + end + p:close() + assert.are.same({ "new" }, marked) -- only the side that lost the newline + assert.is_nil(plain:find("¬", 1, true)) + end) + + -- the staged line edited again: the index's version reaches neither side of the diff + it("says in the winbar when staged content isn't on screen", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 3\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + + local p, v = open_panel() + local text, gutter = winbar_of(v), gutter_of(v, hunk_line(v, 1)) + v:toggle_local() + local local_gutter = gutter_of(v, hunk_line(v, 1)) + p:close() + assert.is_truthy(text:find("1 hidden (dw)", 1, true)) + assert.is_truthy(gutter:find("!", 1, true)) -- the hunk it's hidden in + assert.is_truthy(local_gutter:find("!", 1, true)) -- the local hunk that rewrote it + end) + + it("says in the winbar when a staged mode change isn't on screen", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") -- the index holds +x + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") -- the worktree is back to -x + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + local text = winbar_of(view_in_origin(p)) + p:close() + assert.is_truthy(text:find("hidden (dw)", 1, true)) + end) + + it("names a kept deletion's copy on disk in the winbar", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("a.lua")) + p:select(true) + local v = view_in_origin(p) + local new_rev, text = v.model.new_rev, winbar_of(v) + p:close() + assert.are.equal("INDEX", new_rev) -- the removal, not the copy on disk + assert.is_truthy(text:find("untracked copy on disk", 1, true)) + end) + + -- git reset on an added file drops it from the index, and u taking the last staged + -- line of one does the same rather than staging an empty file + it("drops an added file from the index once nothing of it is staged", function() + local root = fresh_repo() + write(root .. "/new.lua", "one\ntwo\n") + git(root, "add", "new.lua") + write(root .. "/new.lua", "one\ntwo\nthree\n") -- AM + vim.cmd.edit(root .. "/new.lua") + + local p, v = open_panel() + local state = v:_hunk_state(1) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + p:close() + assert.are.equal("partial", state) + assert.are.equal("?? new.lua\n", git(root, "status", "--porcelain=v1", "--", "new.lua")) + end) + + -- a rename only the worktree has made (`git add -N`) keeps the old path in the + -- index, so staging content from it has to stage the move too + it("stages a worktree-only rename along with the content staged from it", function() + local root = fresh_repo() + local lines = {} + for n = 1, 10 do + lines[n] = "line " .. n + end + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "commit", "-q", "-am", "ten lines") + os.rename(root .. "/a.lua", root .. "/b.lua") + lines[1], lines[10] = "line 1 edited", "line 10 edited" + write(root .. "/b.lua", table.concat(lines, "\n") .. "\n") + git(root, "add", "-N", "b.lua") -- " R a.lua -> b.lua" + vim.cmd.edit(root .. "/b.lua") + + local p, v = open_panel() + local hunks = #v.model.hunks + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + p:close() + assert.are.equal(2, hunks) -- read against HEAD's a.lua, not a whole-file add + local status = git(root, "status", "--porcelain=v1") + assert.are.equal("RM a.lua -> b.lua\n", status) + end) + + -- two staged edits and a third on top in the worktree, far enough apart to diff as + -- three hunks + local function partly_staged_repo() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n") + git(root, "commit", "-q", "-am", "twelve lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6x\n7\n8\n9\n10\n11\n12\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6x\n7\n8\n9\n10\n11\n12x\n") + vim.cmd.edit(root .. "/a.lua") + return root + end + + it("dw opens a partly staged file's local view and goes back", function() + partly_staged_repo() + local p, v = open_panel() + local whole = #v.model.hunks + v:toggle_local() + local old_rev, new_rev = v.model.old_rev, v.model.new_rev + local hunks, state, text = #v.model.hunks, v:_hunk_state(1), winbar_of(v) + local gutter = gutter_of(v, hunk_line(v, 1)) + v:toggle_local() + local back_rev, back_hunks, back_text = v.model.old_rev, #v.model.hunks, winbar_of(v) + p:close() + assert.are.equal(3, whole) + assert.are.same({ "INDEX", "WORKTREE" }, { old_rev, new_rev }) + assert.are.equal(1, hunks) + assert.are.equal("unstaged", state) + assert.is_truthy(text:find(" LOCAL ", 1, true)) + assert.is_nil(gutter:find("!", 1, true)) -- 12x touches nothing staged + assert.are.equal("HEAD", back_rev) + assert.are.equal(3, back_hunks) + assert.is_nil(back_text:find("LOCAL", 1, true)) + end) + + -- the view stays frozen and the index is rebuilt from the one it opened on, so a + -- hunk staged here stays on screen and u puts it back + it("stages and unstages in the local view against the index it opened on", function() + local root = partly_staged_repo() + local before = indexed(root, "a.lua") + local p, v = open_panel() + v:toggle_local() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local staged, state, rev_after = indexed(root, "a.lua"), v:_hunk_state(1), v.model.old_rev + v:unstage_hunk() + local unstaged = indexed(root, "a.lua") + p:close() + assert.are.equal(worktree(root, "a.lua"), staged) + assert.are.equal("staged", state) + assert.are.equal("INDEX", rev_after) -- still the local view + assert.are.equal(before, unstaged) + end) + + it("df from the local view edits the worktree file from the whole change", function() + partly_staged_repo() + local p, v = open_panel() + v:toggle_local() + v:edit_file() + local rev_after, win = v.model.old_rev, v.edit_win + p:close() + assert.are.equal("HEAD", rev_after) + assert.is_truthy(win) + end) + + it("dw says so on a file with no local view", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + _G.notifs = {} + v:toggle_local() + local rev_after, msg = v.model.old_rev, _G.notifs[#_G.notifs].msg + p:close() + assert.are.equal("HEAD", rev_after) + assert.are.equal("differ: only a partly staged file has a local view", msg) + end) + + -- HEAD's twelve numbered lines with `edits` (line -> text) applied + local function twelve(edits) + local out = {} + for n = 1, 12 do + out[n] = edits[n] or tostring(n) + end + return table.concat(out, "\n") .. "\n" + end + -- line 1 staged as 1x, then the worktree set to `work` + local function staged_then(work) + local root = fresh_repo() + write(root .. "/a.lua", twelve({})) + git(root, "commit", "-q", "-am", "twelve lines") + write(root .. "/a.lua", twelve({ "1x" })) + git(root, "add", "a.lua") + write(root .. "/a.lua", work) + vim.cmd.edit(root .. "/a.lua") + return root + end + -- stage `content` as a.lua from outside differ, leaving the worktree file alone + local function stage_behind(root, content) + local blob = vim.fn.tempname() + write(blob, content) + local sha = vim.trim(git(root, "hash-object", "-w", blob)) + os.remove(blob) + git(root, "update-index", "--cacheinfo", "100644," .. sha .. ",a.lua") + end + -- the local view of the file :Differ opened, cursor on hunk `n` + local function local_view_at(n) + local p, v = open_panel() + v:toggle_local() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + return p, v + end + + -- 1x staged and then put back on disk: HEAD↔worktree is empty, so the row draws the + -- staged change it still has rather than a notice + it("opens a staged change the worktree put back on its HEAD-to-index hunks", function() + local root = staged_then(twelve({})) + local p, v = open_panel() + local revs = { v.model.old_rev, v.model.new_rev } + local hunks, notice, badge = #v.model.hunks, v.model.notice, v.staging.badge + local state = v:_hunk_state(1) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local prompt = confirming(1, function() + v:unstage_hunk() + end) + local index = indexed(root, "a.lua") + if Panel.current() then + p:close() -- unstaging the last staged change empties the list + end + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal(1, hunks) + assert.is_nil(notice) + assert.are.equal("INDEX", badge) + assert.are.equal("staged", state) + assert.are.equal("Unstage hunk 1/1 in a.lua? The staged change will be lost.", prompt) + assert.are.equal(committed(root, "a.lua"), index) + end) + + -- 1x staged, 12x not: staging the one local hunk leaves nothing since staging to show + it("s on the last hunk in the local view hands back to the whole change", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + local p, v = local_view_at(1) + local hunks = #v.model.hunks + v:stage_hunk() + vim.wait(500, function() -- the hand-back runs on a schedule + return v.staging.badge ~= "LOCAL" + end) + local revs, badge = { v.model.old_rev, v.model.new_rev }, v.staging.badge + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(1, hunks) + assert.are.same({ "HEAD", "WORKTREE" }, revs) + assert.is_nil(badge) + assert.are.equal(twelve({ "1x", [12] = "12x" }), index) + end) + + -- the index holds the content and +x, the worktree the content and -x + it("s in a local view with only a mode change stages the file and hands back", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_local() + local notice = v.model.notice + v:stage_hunk() + vim.wait(500, function() -- the hand-back runs on a schedule + return v.staging.badge ~= "LOCAL" + end) + local badge = v.staging.badge + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.is_truthy(notice:find("^Mode changed"), notice) + assert.is_nil(badge) + assert.are.equal("M a.lua\n", status) + end) + + -- the index holds new content and +x, the worktree the same content and -x + it( + "u in a local view with only a mode change drops the staged mode, keeping the content", + function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_local() + local prompt = confirming(1, function() + v:unstage_hunk() + end) + local badge = v.staging.badge + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local mode = git(root, "ls-files", "-s", "--", "a.lua"):sub(1, 6) + p:close() + assert.are.equal("Unstage a.lua? The staged change will be lost.", prompt) + assert.is_nil(badge) + assert.are.equal("M a.lua\n", status) + assert.are.equal("100644", mode) + end + ) + + it("u in a staged add's local view drops the add", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + v:toggle_local() + confirming(1, function() + v:unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) + + it("u in a local view drops a staged binary add changed again on disk", function() + local root = fresh_repo() + write(root .. "/b.dat", "\0one") + git(root, "add", "b.dat") + write(root .. "/b.dat", "\0two") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.dat", true)) + local v = view_in_origin(p) + v:toggle_local() + confirming(1, function() + v:unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "b.dat") + p:close() + assert.are.equal("?? b.dat\n", status) + end) + + it("u in a binary file's local view puts HEAD's blob back in the index", function() + local root = fresh_repo() + write(root .. "/b.dat", "\0one") + git(root, "add", "b.dat") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.dat", "\0two") + git(root, "add", "b.dat") + write(root .. "/b.dat", "\0three") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.dat", true)) + local v = view_in_origin(p) + v:toggle_local() + confirming(1, function() + v:unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "b.dat") + p:close() + assert.are.equal(" M b.dat\n", status) + end) + + it("X in a local view with only a mode change puts the index's mode back on disk", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_local() + local prompt = confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local perm = vim.fn.getfperm(root .. "/a.lua") + p:close() + assert.are.equal("Revert all of a.lua? This resets it to the index.", prompt) + assert.are.equal("M a.lua\n", status) + assert.are.equal("rwxr-xr-x", perm) + end) + + it("X in a staged add's local view brings the file back from the index", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + v:toggle_local() + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("A c.lua\n", status) + assert.are.equal("one\n", worktree(root, "c.lua")) + end) + + it("X in a binary file's local view puts the index's blob back on disk", function() + local root = fresh_repo() + write(root .. "/b.dat", "\0one") + git(root, "add", "b.dat") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.dat", "\0two") + git(root, "add", "b.dat") + write(root .. "/b.dat", "\0three") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.dat", true)) + local v = view_in_origin(p) + v:toggle_local() + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "b.dat") + p:close() + assert.are.equal("M b.dat\n", status) + end) + + it("X after s on an untracked file acts on the file as s left it", function() + local root = fresh_repo() + write(root .. "/new.lua", "new\n") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/new.lua") + local p, v = open_panel() + v:stage_hunk() + vim.wait(30) + _G.notifs = {} + local prompt = confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + local status = git(root, "status", "--porcelain=v1", "--", "new.lua") + p:close() + assert.are.equal("Revert all of new.lua? This deletes the file.", prompt) + assert.is_nil(said and said:find("skipped", 1, true), said) + assert.are.equal("", status) + end) + + it("X after s on a deleted file brings it back", function() + local root = fresh_repo() + os.remove(root .. "/a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + local p = open_panel() + assert.is_true(p:goto_path("a.lua", true)) + view_in_origin(p):stage_hunk() + vim.wait(30) + confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local back = vim.fn.filereadable(root .. "/a.lua") + p:close() + assert.are.equal("", status) + assert.are.equal(1, back) + end) + + -- a.lua moved to b.lua, edited, staged, and then b.lua deleted: RD + it("s after u on a moved file that was then deleted stages what the rows are now", function() + local root = fresh_repo() + write(root .. "/a.lua", twelve({})) + git(root, "commit", "-q", "-am", "twelve lines") + git(root, "mv", "a.lua", "b.lua") + write(root .. "/b.lua", twelve({ [6] = "6x" })) + git(root, "add", "b.lua") + os.remove(root .. "/b.lua") + write(root .. "/c.lua", "c\n") + vim.cmd.edit(root .. "/c.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.lua", true)) + local row = git(root, "status", "--porcelain=v1", "--", "a.lua", "b.lua") + confirming(1, function() + view_in_origin(p):unstage_hunk() + end) + vim.wait(30) + _G.notifs = {} + view_in_origin(p):stage_hunk() + vim.wait(30) + local failed = vim.tbl_filter(function(n) + return n.msg:find("failed", 1, true) ~= nil + end, _G.notifs) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua", "b.lua") + p:close() + assert.are.equal("RD a.lua -> b.lua\n", row) + assert.are.same({}, failed) + assert.are.equal("D a.lua\n", status) + end) + + it("says where X undoes a bare move instead of calling it a hunk", function() + local root = fresh_repo() + git(root, "mv", "a.lua", "b.lua") + write(root .. "/c.lua", "c\n") + vim.cmd.edit(root .. "/c.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.lua", true)) + _G.notifs = {} + view_in_origin(p):revert_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal("differ: X on the panel row undoes the move", said) + end) + + it("says why a staged edit deleted on disk has no local view", function() + local root = fresh_repo() + write(root .. "/a.lua", "edited\n") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + local p = open_panel() + assert.is_true(p:goto_path("a.lua", true)) + _G.notifs = {} + view_in_origin(p):toggle_local() + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal( + "differ: a.lua's only local change is its deletion, which s stages here", + said + ) + end) + + it("names what X drops on a staged add whose file was deleted", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local prompt = confirming(2, function() + view_in_origin(p):revert_hunk() + end) + p:close() + assert.are.equal("Revert all of c.lua? This deletes the staged file.", prompt) + end) + + it("says so when the commit preview empties and hands back every change", function() + local root = fresh_repo() + write(root .. "/a.lua", "staged\n") + git(root, "add", "a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_commit_preview() + _G.notifs = {} + view_in_origin(p):unstage_all() + vim.wait(30) + local said = vim.tbl_map(function(n) + return n.msg + end, _G.notifs) + p:close() + assert.is_true(vim.tbl_contains(said, "differ: nothing staged: back to every change")) + end) + + it("says nothing is staged when X in the commit preview follows u on it", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + write(root .. "/b.lua", "b\n") + git(root, "add", "b.lua") + vim.cmd.edit(root .. "/c.lua") + local p, v = open_panel() + v:toggle_commit_preview() + v = view_in_origin(p) + v:unstage_hunk() + _G.notifs = {} + confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal("differ: c.lua has nothing staged now: s stages it back", said) + assert.are.equal(1, vim.fn.filereadable(root .. "/c.lua")) + end) + + it("X on a file whose only change is its mode puts HEAD's mode back", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local prompt = confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + local perm = vim.fn.getfperm(root .. "/a.lua") + p:close() + assert.are.equal("Revert all of a.lua? This resets it to HEAD.", prompt) + assert.are.equal("", status) + assert.are.equal("rw-r--r--", perm) + end) + + it("X on a binary file puts it back as HEAD has it", function() + local root = fresh_repo() + write(root .. "/b.dat", "\0one") + git(root, "add", "b.dat") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.dat", "\0two") + git(root, "add", "b.dat") + write(root .. "/b.dat", "\0three") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("b.dat", true)) + confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) + + -- the index holds +x, the worktree +x and an edit on top that the preview doesn't show + it("X in the commit preview refuses a whole-file row with unstaged changes on top", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + write(root .. "/a.lua", "edited\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_commit_preview() + v = view_in_origin(p) + _G.notifs = {} + v:revert_hunk() + local said = _G.notifs[#_G.notifs].msg + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("differ: X would also discard the unstaged changes to a.lua", said) + assert.are.equal("MM a.lua\n", status) + assert.are.equal("edited\n", worktree(root, "a.lua")) + end) + + -- the index adds three lines under a and turns p into q; the file also turns h into q + it("X in the commit preview reverts the right lines after u moves the index", function() + local root = fresh_repo() + write(root .. "/a.lua", "a\nb\nc\nd\ne\np\nf\ng\nh\ni\n") + git(root, "commit", "-q", "-am", "base") + write(root .. "/a.lua", "a\nx1\nx2\nx3\nb\nc\nd\ne\nq\nf\ng\nh\ni\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "a\nx1\nx2\nx3\nb\nc\nd\ne\nq\nf\ng\nq\ni\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_commit_preview() + v = view_in_origin(p) + local win = v.columns[#v.columns].winid + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 2), 0 }) + confirming(1, function() + v:revert_hunk() + end) + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + p:close() + assert.are.equal("a\nb\nc\nd\ne\np\nf\ng\nh\ni\n", index) + assert.are.equal("a\nx1\nx2\nx3\nb\nc\nd\ne\np\nf\ng\nq\ni\n", work) + end) + + -- the index drops c and d; the file still has them, so the row opens on HEAD↔index + it("X on a deletion the file already put back moves only the index", function() + local root = fresh_repo() + write(root .. "/a.lua", "a\nb\nc\nd\ne\nf\n") + git(root, "commit", "-q", "-am", "base") + write(root .. "/a.lua", "a\nb\ne\nf\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "a\nb\nc\nd\ne\nf\n") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local badge = v.staging.badge + confirming(1, function() + v:revert_hunk() + end) + local work = worktree(root, "a.lua") + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.are.equal("INDEX", badge) + assert.are.equal("a\nb\nc\nd\ne\nf\n", work) + assert.are.equal("", status) + end) + + it("X on a staged edit the file already put back moves only the index", function() + local root = fresh_repo() + write(root .. "/a.lua", "a\nb\nc\nd\n") + git(root, "commit", "-q", "-am", "base") + write(root .. "/a.lua", "a\nB\nc\nd\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "a\nb\nc\nd\n") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + _G.notifs = {} + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal("", status) + assert.is_nil(said and said:find("nothing reverted", 1, true), said) + end) + + it("X in the commit preview reads the file again before discarding it", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_commit_preview() + write(root .. "/a.lua", "edited\n") -- before the watcher has re-read it + _G.notifs = {} + confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.is_truthy( + said:find("X would also discard the unstaged changes to a.lua", 1, true), + said + ) + assert.are.equal("edited\n", worktree(root, "a.lua")) + end) + + it("X in the commit preview still discards a whole-file row with nothing on top", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_commit_preview() + confirming(1, function() + view_in_origin(p):revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) + + -- 1x staged and then put back to 1 in the worktree: the local hunk undoing it is `!` + it("u on a ! hunk in the local view drops the staged change it undoes", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + local marked = v.staging.hidden_in + confirming(1, function() + v:unstage_hunk() + end) + local index, hunks, rev_after = indexed(root, "a.lua"), #v.model.hunks, v.model.old_rev + local after = v.staging.hidden_in or {} + p:close() + assert.are.same({ 1 }, marked) + assert.are.equal(committed(root, "a.lua"), index) + assert.are.equal(1, hunks) -- only 12x is left to stage + assert.are.equal("HEAD", rev_after) -- nothing staged: the whole change takes over + assert.are.same({}, after) + end) + + -- 1x staged and then put back on disk, with b.lua keeping the session open + it("hands back to the whole change once u leaves the local view nothing to show", function() + local root = staged_then(twelve({})) + write(root .. "/b.lua", "b\n") + local p, v = local_view_at(1) + confirming(1, function() + v:unstage_hunk() + end) + vim.wait(30) + local badge = view_in_origin(p).staging.badge + p:close() + assert.are_not.equal("LOCAL", badge) + end) + + it( + "hands back to the whole change once s and X leave the local view nothing to show", + function() + local root = staged_then(twelve({ "1x", [6] = "6x", [12] = "12x" })) + local p, v = local_view_at(1) + v:stage_hunk() + vim.api.nvim_win_set_cursor(v.columns[#v.columns].winid, { hunk_line(v, 2), 0 }) + confirming(1, function() + v:revert_hunk() + end) + vim.wait(30) + local badge = view_in_origin(p).staging.badge + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + p:close() + assert.are_not.equal("LOCAL", badge) + assert.are.equal("M a.lua\n", status) + end + ) + + -- 10-12 staged as ten eleven twelve, then only eleven rewritten on disk + it("u on a ! hunk takes back only the lines it rewrote", function() + local head = twelve({}) + local index = twelve({ [10] = "ten", [11] = "eleven", [12] = "twelve" }) + local root, p, v = + staged_as(head, index, twelve({ [10] = "ten", [11] = "ELEVEN", [12] = "twelve" })) + v:toggle_local() + local win = v.columns[#v.columns].winid + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 1), 0 }) + local prompt = confirming(1, function() + v:unstage_hunk() + end) + local written = indexed(root, "a.lua") + p:close() + assert.are.equal("Unstage hunk 1/1 in a.lua? The staged change will be lost.", prompt) + assert.are.equal(twelve({ [10] = "ten", [12] = "twelve" }), written) + end) + + -- 10-12 staged as two lines, so no index line has one HEAD line of its own to go back to + it("u on a ! hunk names the whole staged block it takes back", function() + local head = twelve({}) + local index = lines_of({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "ten", "eleven" }) + local work = lines_of({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "ten", "ELEVEN" }) + local root, p, v = staged_as(head, index, work) + v:toggle_local() + local win = v.columns[#v.columns].winid + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { hunk_line(v, 1), 0 }) + local prompt = confirming(1, function() + v:unstage_hunk() + end) + local written = indexed(root, "a.lua") + p:close() + assert.are.equal("Unstage lines 10-11 in a.lua? The staged change will be lost.", prompt) + assert.are.equal(head, written) + end) + + it("doesn't mark an edit to a staged add as ! in the local view", function() + local root = fresh_repo() + write(root .. "/c.lua", twelve({})) + git(root, "add", "c.lua") + write(root .. "/c.lua", twelve({ [5] = "five" })) + vim.cmd.edit(root .. "/c.lua") + local p, v = open_panel() + v:toggle_local() + local marked = v.staging.hidden_in + local status = git(root, "status", "--porcelain=v1", "--", "c.lua") + p:close() + assert.are.same({}, marked or {}) + assert.are.equal("AM c.lua\n", status) + end) + + -- 1x staged and then put back, 12x unstaged: u from 12x walks back onto the ! hunk + it("u walks back onto a ! hunk in the local view rather than off the file", function() + staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(2) + v:unstage_hunk() + local at, badge = v:_hunk_index_under_cursor(), v.staging.badge + p:close() + assert.are.equal(1, at) + assert.are.equal("LOCAL", badge) + end) + + -- 1x staged and then edited to 1y: u gives the index HEAD's line, where s gives 1y + it("u on a ! hunk takes HEAD's lines, not the worktree's", function() + local root = staged_then(twelve({ "1y", [12] = "12x" })) + local p, v = local_view_at(1) + confirming(1, function() + v:unstage_hunk() + end) + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(committed(root, "a.lua"), index) + end) + + -- it drops staged content no other side holds, so a no leaves the index alone + it("u on a ! hunk drops nothing when the confirm is declined", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + confirming(2, function() + v:unstage_hunk() + end) + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(twelve({ "1x" }), index) + end) + + -- where u drops the staged 1x, X keeps it: the put-back goes and the file holds 1x again + it("X on a ! hunk in the local view puts the staged line back into the file", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + local opened = v.staging + confirming(1, function() + v:revert_hunk() + end) + vim.wait(500, function() -- the local view re-reads on a schedule + return v.staging ~= opened + end) + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + local hidden = v.staging.hidden_in + p:close() + assert.are.equal(twelve({ "1x", [12] = "12x" }), work) + assert.are.equal(twelve({ "1x" }), index) + assert.are.same({}, hidden) + end) + + -- the rebuild starts from the index the view opened on, so a write made outside + -- differ since then would be lost to it + it("s in the local view refuses an index staged from outside since it opened", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + local p, v = local_view_at(1) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + v:stage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) + + it("u on a ! hunk refuses an index staged from outside since the view opened", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + confirming(1, function() + v:unstage_hunk() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) + + -- 5x staged and put back on disk, 0 added above it: s on the 0 moves the 5x down a line + it("u on a ! hunk finds its line below a hunk s staged in the same view", function() + local root = fresh_repo() + write(root .. "/a.lua", twelve({})) + git(root, "commit", "-q", "-am", "twelve lines") + stage_behind(root, twelve({ [5] = "5x" })) + write(root .. "/a.lua", "0\n" .. twelve({ [12] = "12x" })) + vim.cmd.edit(root .. "/a.lua") + local p, v = local_view_at(1) + local marked = v.staging.hidden_in + v:stage_hunk() + vim.api.nvim_win_set_cursor(0, { hunk_line(v, 2), 0 }) + confirming(1, function() + v:unstage_hunk() + end) + local index = indexed(root, "a.lua") + p:close() + assert.are.same({ 2 }, marked) + assert.are.equal("0\n" .. twelve({}), index) + end) + + -- 1x staged, 6y and 12x not: staging 6y from outside leaves the row MM + it("re-marks a partial stage from outside that leaves the row as it was", function() + local root, p, v = + staged_as(twelve({}), twelve({ "1x" }), twelve({ "1x", [6] = "6y", [12] = "12x" })) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local before = states() + stage_behind(root, twelve({ "1x", [6] = "6y" })) + p.on_external_change() + local after = states() + p:close() + local S, U = "staged", "unstaged" + assert.are.same({ S, U, U }, before) + assert.are.same({ S, S, U }, after) + end) + + -- git reads `:1:x.txt` as conflict stage 1 of x.txt + it("reads the index of a file whose name starts with a stage number", function() + local root = fresh_repo() + write(root .. "/1:x.txt", twelve({})) + git(root, "add", "1:x.txt") + git(root, "commit", "-q", "-m", "twelve lines") + index_as(root, "1:x.txt", twelve({ "1x" })) + write(root .. "/1:x.txt", twelve({ "1x", [12] = "12x" })) + vim.cmd.edit(vim.fn.fnameescape(root .. "/1:x.txt")) + local p, v = open_panel() + local states = { v:_hunk_state(1), v:_hunk_state(2) } + p:close() + assert.are.same({ "staged", "unstaged" }, states) + end) + + it("refuses s when the index can't be read", function() + local root, p, v = staged_as(twelve({}), twelve({ "1x" }), twelve({ "1x", [12] = "12x" })) + local read = git_src.read + git_src.read = function(ref, ...) + if ref.kind == "index" then + return nil + end + return read(ref, ...) + end + _G.notifs = {} + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + git_src.read = read + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: couldn't read a.lua from the index", said) + assert.are.equal(twelve({ "1x" }), index) + end) + + it("X in the local view puts the worktree hunk back to the index's version", function() + local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) + local p, v = local_view_at(1) + local opened = v.staging + confirming(1, function() + v:revert_hunk() + end) + vim.wait(500, function() -- the local view re-reads on a schedule + return v.staging ~= opened + end) + local work, index, hunks = worktree(root, "a.lua"), indexed(root, "a.lua"), #v.model.hunks + p:close() + assert.are.equal(twelve({ "1x", [12] = "12y" }), work) + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(1, hunks) + end) + + -- s put 6y in the index; throwing the hunk away takes it out of both + it("X on a hunk staged in the local view drops it from the index too", function() + local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) + local p, v = local_view_at(1) + v:stage_hunk() + confirming(1, function() + v:revert_hunk() + end) + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + p:close() + assert.are.equal(twelve({ "1x", [12] = "12y" }), work) + assert.are.equal(twelve({ "1x" }), index) + end) + + -- ds, from the panel + local function toggle_preview(p) + p.extra_keymaps[1].fn() + end + local function titles(p) + local out = {} + for _, sec in ipairs(p.sections) do + out[#out + 1] = sec.title + end + return out + end + local function paths(p) + local out = {} + for _, sec in ipairs(p.sections) do + for _, e in ipairs(sec.entries) do + out[#out + 1] = e.path + end + end + table.sort(out) + return out + end + -- the partly staged a.lua plus a staged add c.lua (edited again) and an unstaged b.lua + local function preview_repo() + local root = partly_staged_repo() + write(root .. "/b.lua", "b\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b", "--", "b.lua") -- a.lua's staged edits stay staged + write(root .. "/b.lua", "b2\n") + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + write(root .. "/c.lua", "one\ntwo\n") + return root + end + + -- dw, from the panel: extra_keymaps[2], where [1] is ds + local function toggle_local_key(p) + p.extra_keymaps[2].fn() + end + + it("dw in the panel opens the row's local view, and takes it back", function() + local root = partly_staged_repo() + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + toggle_local_key(p) + local v = view_in_origin(p) + local revs, badge = { v.model.old_rev, v.model.new_rev }, v.staging.badge + toggle_local_key(p) + v = view_in_origin(p) + local back = { v.model.old_rev, v.model.new_rev } + p:close() + assert.are.same({ "INDEX", "WORKTREE" }, revs) + assert.are.equal("LOCAL", badge) + assert.are.same({ "HEAD", "WORKTREE" }, back) + end) + + it("dw in the panel refuses in the commit preview", function() + preview_repo() + local p = open_panel() + toggle_preview(p) + _G.notifs = {} + toggle_local_key(p) + local said, rev_after = (_G.notifs[#_G.notifs] or {}).msg, view_in_origin(p).model.new_rev + p:close() + assert.are.equal("differ: the commit preview has no local view: ds goes back", said) + assert.are.equal("INDEX", rev_after) + end) + + -- the first s turns a.lua Partial and the last makes it Staged, with no re-source + -- between: dw follows the row rather than the state the diff opened on + it("offers dw once the first s leaves a row partly staged, and not after the last", function() + local root = fresh_repo() + local ten = {} + for i = 1, 10 do + ten[i] = tostring(i) + end + write(root .. "/a.lua", table.concat(ten, "\n") .. "\n") + git(root, "commit", "-q", "-am", "ten") + ten[2], ten[9] = "2x", "9x" + write(root .. "/a.lua", table.concat(ten, "\n") .. "\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local col = v.columns[1] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + v:toggle_local() + local partial_badge = view_in_origin(p).staging.badge + view_in_origin(p):toggle_local() -- back to the whole change + v = view_in_origin(p) + col = v.columns[1] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + _G.notifs = {} + v:toggle_local() + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal("LOCAL", partial_badge) + assert.are.equal("differ: only a partly staged file has a local view", said) + end) + + it("dw in the panel refuses a row with nothing staged", function() + local root = fresh_repo() + write(root .. "/a.lua", "one\ntwo\n") + git(root, "add", "a.lua") + git(root, "commit", "-q", "-m", "one") + write(root .. "/a.lua", "one\ntwo2\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + _G.notifs = {} + toggle_local_key(p) + local said, rev_after = (_G.notifs[#_G.notifs] or {}).msg, view_in_origin(p).model.old_rev + p:close() + assert.are.equal("differ: only a partly staged file has a local view", said) + assert.are.equal("HEAD", rev_after) + end) + + it("ds in the diff flips the listing the same way the panel's does", function() + preview_repo() + local p, v = open_panel() + local before = titles(p) + local bound = false + for _, map in ipairs(vim.api.nvim_buf_get_keymap(v.columns[#v.columns].bufnr, "n")) do + bound = bound or map.lhs == "ds" + end + v:toggle_commit_preview() + local in_titles, in_badge = titles(p), view_in_origin(p).staging.badge + view_in_origin(p):toggle_commit_preview() + local out_titles, out_badge = titles(p), view_in_origin(p).staging.badge + p:close() + assert.is_true(bound) + assert.are.same({ "Staged changes" }, in_titles) + assert.are.equal("STAGED", in_badge) + assert.are.same(before, out_titles) + assert.is_nil(out_badge) + end) + + it("ds lists only the staged changes, each diffing HEAD-to-index, and back", function() + preview_repo() + local p = open_panel() + local before = titles(p) + toggle_preview(p) + local v = view_in_origin(p) + local in_titles, in_paths = titles(p), paths(p) + local path, revs = v.model.path, { v.model.old_rev, v.model.new_rev } + local state, text = v:_hunk_state(1), winbar_of(v) + toggle_preview(p) + local out_titles, out_rev = titles(p), v.model.new_rev + p:close() + assert.are.same({ "Staged changes" }, in_titles) + assert.are.same({ "a.lua", "c.lua" }, in_paths) + assert.are.equal("a.lua", path) -- the file on screen stays + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal("staged", state) + assert.is_truthy(text:find(" STAGED ", 1, true)) + assert.is_truthy(text:find(" hunk 1/2 ", 1, true)) + assert.is_nil(text:find("staged ·", 1, true)) -- every hunk staged: no tally + assert.are.same(before, out_titles) + assert.are.equal("WORKTREE", out_rev) + end) + + -- frozen like the local view: the index is rebuilt from HEAD plus the hunks still + -- marked staged, so hunks come out in any order and go straight back + it("unstages in the commit preview in any order, and stages back in place", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:unstage_hunk() + local second_out = indexed(root, "a.lua") + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + local both_out = indexed(root, "a.lua") + v:stage_hunk() + local first_back = indexed(root, "a.lua") + local hunks, rev_after = #v.model.hunks, v.model.new_rev + p:close() + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", second_out) + assert.are.equal(committed(root, "a.lua"), both_out) + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", first_back) + assert.are.equal(2, hunks) + assert.are.equal("INDEX", rev_after) + end) + + -- an add is one unit whose index entry goes and comes back: s restores what was + -- staged, not the worktree's copy + it("unstages an add in the commit preview and stages back what was staged", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + confirming(1, function() + v:unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1", "--", "c.lua") + v:stage_hunk() + local back = indexed(root, "c.lua") + p:close() + assert.are.equal("?? c.lua\n", status) + assert.are.equal("one\n", back) + end) + + it("s in the commit preview refuses an add staged from outside since u", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + confirming(1, function() + v:unstage_hunk() + end) + write(root .. "/c.lua", "three\n") + git(root, "add", "c.lua") + _G.notifs = {} + v:stage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "c.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal("three\n", index) + end) + + it("u in the commit preview refuses an add staged again from outside", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + git(root, "add", "c.lua") + _G.notifs = {} + v:unstage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "c.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal("one\ntwo\n", index) + end) + + it("refuses s in the commit preview panel", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) + local before = indexed(root, "a.lua") + _G.notifs = {} + p:stage_op("stage_all") + local msg, after = _G.notifs[#_G.notifs].msg, indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the commit preview only unstages: ds goes back", msg) + assert.are.equal(before, after) + end) + + -- an MD row is lettered D in the whole list but M in the preview + it("discards an edit staged and then deleted from the commit preview panel", function() + local root = preview_repo() + os.remove(root .. "/a.lua") + vim.cmd.edit(root .. "/c.lua") -- a buffer on a file still on disk resolves the repo + local p = open_panel() + toggle_preview(p) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + _G.notifs = {} + confirming(1, function() + p:discard() + end) + local skipped = false + for _, n in ipairs(_G.notifs) do + skipped = skipped or n.msg:find("discard skipped", 1, true) ~= nil + end + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local on_disk = vim.fn.filereadable(root .. "/a.lua") == 1 + local work = on_disk and worktree(root, "a.lua") or nil + p:close() + assert.is_false(skipped) + assert.are.equal("", status) + assert.are.equal(committed(root, "a.lua"), work) + end) + + -- an RD: a.lua renamed to b.lua in the index, then b.lua deleted from disk. the row + -- is lettered D in the whole list and R in the preview + local function renamed_then_deleted() + local root = fresh_repo() + write(root .. "/z.lua", "z\n") + git(root, "add", "z.lua") + git(root, "commit", "-q", "-m", "z") + git(root, "mv", "a.lua", "b.lua") + os.remove(root .. "/b.lua") + vim.cmd.edit(root .. "/z.lua") + return root + end + ---@param root string + ---@param preview boolean + ---@return { complaints: string[], status: string, a: string|nil, b_on_disk: boolean } + local function discard_renamed_then_deleted(root, preview) + local p = open_panel() + if preview then + toggle_preview(p) + end + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua"), 0 }) + _G.notifs = {} + confirming(1, function() + p:discard() + end) + local out = { complaints = {} } + for _, n in ipairs(_G.notifs) do + if n.msg:find("discard", 1, true) then + out.complaints[#out.complaints + 1] = n.msg + end + end + out.status = git(root, "status", "--porcelain=v1") + if vim.fn.filereadable(root .. "/a.lua") == 1 then + out.a = worktree(root, "a.lua") + end + out.b_on_disk = vim.fn.filereadable(root .. "/b.lua") == 1 + p:close() + return out + end + + it("brings back a staged rename whose new path was deleted, from the whole list", function() + local root = renamed_then_deleted() + local got = discard_renamed_then_deleted(root, false) + assert.are.equal("R a.lua -> b.lua\n", got.status) + assert.is_nil(got.a) + assert.is_true(got.b_on_disk) + assert.are.same({}, got.complaints) + end) + + it("unstages both ends of a staged rename whose new path was deleted", function() + local root = renamed_then_deleted() + local p = open_panel() + assert.is_true(p:goto_path("b.lua", true)) + view_in_origin(p):unstage_hunk() + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal(" D a.lua\n", status) + end) + + -- an AD: HEAD and the worktree both lack c.lua, so only the index shows the add + it("draws a staged add whose file was deleted as HEAD-to-index", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + local revs, hunks, badge = + { v.model.old_rev, v.model.new_rev }, #v.model.hunks, v.staging.badge + local prompt = confirming(1, function() + v:unstage_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal(1, hunks) + assert.are.equal("INDEX", badge) + assert.are.equal("Unstage c.lua? The staged change will be lost.", prompt) + assert.are.equal("", status) + end) + + -- c.lua was added and then deleted: its whole change is the staged add, and only its + -- local view shows the deletion, so the s walk stops on it rather than passing it + it("stops s on a staged add whose file was deleted", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local landed = {} + for _ = 1, 5 do + view_in_origin(p):stage_hunk() + landed[#landed + 1] = view_in_origin(p).model.path + end + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.is_not_nil(v) + assert.are.same({ "a.lua", "c.lua", "c.lua", "c.lua", "c.lua" }, landed) + assert.are.equal("differ: c.lua has local changes: dw shows them", said) + end) + + -- b.lua staged then put back on disk: passed once, and back in the walk once it changes + it("puts a passed row back in the walk when its file changes", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.lua", "two\n") + git(root, "add", "b.lua") + write(root .. "/b.lua", "one\n") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + view_in_origin(p):stage_hunk() -- a.lua staged + view_in_origin(p):stage_hunk() -- onto b.lua + view_in_origin(p):stage_hunk() -- b.lua passed: nothing left + write(root .. "/b.lua", "three\n") + p:refresh() + assert.is_true(p:goto_path("a.lua", true)) + view_in_origin(p):stage_hunk() -- a.lua is done: the walk looks again + local landed = view_in_origin(p).model.path + p:close() + assert.are.equal("b.lua", landed) + end) + + -- a.lua and c.lua half staged, d.lua not at all: finishing c.lua moves it up into + -- Staged, and the walk goes on to d.lua below it rather than back up to a.lua + it("walks on from where a finished file sat, not from where it moved", function() + local root = fresh_repo() + for _, name in ipairs({ "c.lua", "d.lua" }) do + write(root .. "/" .. name, "one\n") + end + git(root, "add", "c.lua", "d.lua") + git(root, "commit", "-q", "-m", "c d") + write(root .. "/a.lua", "staged\n") + write(root .. "/c.lua", "staged\n") + git(root, "add", "a.lua", "c.lua") + write(root .. "/a.lua", "staged\nand more\n") + write(root .. "/c.lua", "staged\nand more\n") + write(root .. "/d.lua", "two\n") + vim.cmd.edit(root .. "/c.lua") + local p = open_panel() + assert.are.equal("c.lua", view_in_origin(p).model.path) + view_in_origin(p):stage_hunk() -- c.lua's rest: the file moves to Staged + view_in_origin(p):stage_hunk() -- on to the next file + local landed = view_in_origin(p).model.path + p:close() + assert.are.equal("d.lua", landed) + end) - p:stage_op("stage_all") -- both halves reach the index, and git pairs them + it("discards a staged rename whose new path was deleted, from the commit preview", function() + local root = renamed_then_deleted() + local got = discard_renamed_then_deleted(root, true) + assert.are.equal("", got.status) + assert.are.equal(committed(root, "a.lua"), got.a) + assert.is_false(got.b_on_disk) + assert.are.same({}, got.complaints) + end) - -- old.lua has no entry left at all, so the window moves to the rename rather - -- than sitting on a deletion the change set no longer holds - assert.are.equal("new.lua", view_in_origin(p).model.path) + it("returns to the whole list once the commit preview is unstaged", function() + preview_repo() + local p = open_panel() + toggle_preview(p) + p:stage_op("unstage_all") + local alive, got = p:is_alive(), titles(p) + local rev_after = view_in_origin(p).model.new_rev p:close() + assert.is_true(alive) + assert.are.same({ "Unstaged", "Untracked" }, got) + assert.are.equal("WORKTREE", rev_after) end) - it("opens on the current file, snapping to the hunk nearest the cursor", function() - local root = fresh_repo() - write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") - git(root, "commit", "-q", "-am", "ten lines") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- hunks at lines 1 and 10 - write(root .. "/m.lua", "new\n") - git(root, "add", "m.lua") -- a staged file that sorts ahead of a.lua + it("df from the commit preview edits the file from the whole list", function() + preview_repo() + local p = open_panel() + toggle_preview(p) + local v = view_in_origin(p) + v:edit_file() + local got, rev_after, win = titles(p), v.model.new_rev, v.edit_win + p:close() + assert.are.same({ "Partial", "Unstaged" }, got) + assert.are.equal("WORKTREE", rev_after) + assert.is_truthy(win) + end) - vim.cmd.edit(root .. "/a.lua") - vim.api.nvim_win_set_cursor(0, { 10, 0 }) -- near the second hunk + -- confirm X's prompt, then wait out the re-read the preview schedules + local function revert_confirmed(v) + local opened = v.staging + confirming(1, function() + v:revert_hunk() + end) + vim.wait(500, function() + return v.staging ~= opened + end) + end - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + it("X in the commit preview drops a staged hunk from the index and the file", function() + local root = preview_repo() + local p = open_panel() + toggle_preview(p) local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + revert_confirmed(v) + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + local hunks, rev_after = #v.model.hunks, v.model.new_rev + p:close() + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(twelve({ "1x", [12] = "12x" }), work) + assert.are.equal(1, hunks) + assert.are.equal("INDEX", rev_after) -- still the preview + end) - -- opened a.lua (the current file) though m.lua sorts first in the list - assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n", v.model.new_text) - -- and snapped to the second hunk (nearest the cursor), not the first + -- 1x staged and then edited to 1y: taking 1x out of the file would lose 1y + it("X in the commit preview refuses a hunk the file has changed since staging", function() + local root = staged_then(twelve({ "1y", [12] = "12x" })) + local p = open_panel() + toggle_preview(p) + local v = view_in_origin(p) local col = v.columns[#v.columns] - local row = vim.api.nvim_win_get_cursor(col.winid)[1] - assert.are.equal(2, col.map.lines[row].hunk) + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + _G.notifs = {} + confirming(1, function() + v:revert_hunk() + end) + local msg = _G.notifs[#_G.notifs].msg + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() + assert.are.equal("differ: the file has changed these lines: nothing reverted", msg) + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(twelve({ "1y", [12] = "12x" }), work) end) - -- an "MM" file lists under Staged and Unstaged, and Staged renders first. the origin - -- line is a worktree line, so it only means anything against the index↔worktree pair: - -- landing on the staged row reads it in index coordinates and snaps to a stray hunk - it("opens the unstaged side of an MM file, holding the exact origin line", function() + -- the index drops l6 l7 and the worktree does too, with three lines added on top: + -- the deletion's index line number is three short of where it sits in the file + it("X in the commit preview puts a staged deletion back where the file has it", function() local root = fresh_repo() - write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + local head = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n" + write(root .. "/a.lua", head) git(root, "commit", "-q", "-am", "ten lines") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") -- staged: a hunk at line 1 + write(root .. "/a.lua", "l1\nl2\nl3\nl4\nl5\nl8\nl9\nl10\n") git(root, "add", "a.lua") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- unstaged: a hunk at line 10 - + write(root .. "/a.lua", "n1\nn2\nn3\nl1\nl2\nl3\nl4\nl5\nl8\nl9\nl10\n") vim.cmd.edit(root .. "/a.lua") - vim.api.nvim_win_set_cursor(0, { 10, 2 }) -- on the unstaged change - - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() + toggle_preview(p) local v = view_in_origin(p) - - -- the index↔worktree pair, not HEAD↔index (whose hunk sits at line 1) - assert.are.equal(indexed(root, "a.lua"), v.model.old_text) - assert.are.equal(worktree(root, "a.lua"), v.model.new_text) - - -- line 10 is a changed line there, so it's held exactly (column included) - -- rather than falling back to the nearest hunk local col = v.columns[#v.columns] - local row, ccol = unpack(vim.api.nvim_win_get_cursor(col.winid)) - assert.are.equal(10, col.map.lines[row].new) - assert.is_not_nil(col.map.lines[row].hunk) - assert.are.equal(2, ccol) + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + revert_confirmed(v) + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() + assert.are.equal(head, index) + assert.are.equal("n1\nn2\nn3\n" .. head, work) end) - -- preferring the unstaged row only means preferring it: a file staged outright has - -- no unstaged side to land on, so the staged row is the one it has - it("falls back to the staged row for a fully staged origin file", function() - local root = fresh_repo() - write(root .. "/a.lua", "local x = 2\nreturn x\n") - git(root, "add", "a.lua") -- staged, with nothing left in the worktree - vim.cmd.edit(root .. "/a.lua") + it("u in the commit preview refuses an index staged from outside since it opened", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + local p = open_panel() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + v:unstage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + it("X in the commit preview deletes a staged add", function() + local root = preview_repo() + git(root, "add", "c.lua") -- nothing unstaged on top, which X would refuse + local p = open_panel() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) local v = view_in_origin(p) - assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) - assert.are.equal(V1, v.model.old_text) -- HEAD↔index, the only pair it has - assert.are.equal(indexed(root, "a.lua"), v.model.new_text) + confirming(1, function() + v:revert_hunk() + end) + local on_disk = vim.fn.filereadable(root .. "/c.lua") + local status = git(root, "status", "--porcelain=v1", "--", "c.lua") p:close() + assert.are.equal(0, on_disk) + assert.are.equal("", status) end) - -- staging a file's last unstaged hunk drops its Unstaged row, so every row below - -- slides up one. restoring the panel cursor by line number then lands it on a - -- different file, and ]f / [f step from there - it("keeps the panel cursor on its file when the row leaves the section", function() - local root = fresh_repo() - write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") - write(root .. "/z.lua", "z1\nz2\n") - git(root, "add", "z.lua") - git(root, "commit", "-q", "-am", "two files") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n") -- staged: a hunk at line 1 - git(root, "add", "a.lua") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- unstaged: a hunk at line 8 - write(root .. "/z.lua", "z1x\nz2\n") -- a neighbour to slide into the vacated row + it("dw and ds say why they do nothing", function() + preview_repo() + local p = open_panel() + toggle_preview(p) + local v = view_in_origin(p) + _G.notifs = {} + v:toggle_local() + local dw_msg = _G.notifs[#_G.notifs].msg + p:close() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - vim.api.nvim_win_set_cursor(0, { 8, 0 }) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- a.lua's index↔worktree side - local before = p:current_entry() - assert.are.equal("a.lua", before.path) - assert.is_falsy(before.staged) - - -- the open landed on the line-8 hunk already; stage it, emptying the unstaged side - vim.api.nvim_set_current_win(p.origin_win) - local col = v.columns[#v.columns] - assert.is_not_nil(col.map.lines[vim.api.nvim_win_get_cursor(col.winid)[1]].hunk) - v:stage_hunk() - assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) - - -- a.lua moved to the Staged section; the cursor follows the file there rather - -- than staying on the line z.lua just slid into - local after = p:current_entry() - assert.are.equal("a.lua", after.path) - assert.is_true(after.staged) - -- and the selection ]f / [f step from tracks it too - assert.are.equal("a.lua", p.meta[p.selected_row].entry.path) + p = Panel.current() + _G.notifs = {} + toggle_preview(p) + local ds_msg, got = _G.notifs[#_G.notifs].msg, titles(p) p:close() + assert.are.equal("differ: the commit preview has no local view: ds goes back", dw_msg) + assert.are.equal("differ: nothing staged to preview", ds_msg) + assert.are.same({ "Unstaged" }, got) end) - -- staging a file's last unstaged hunk leaves the index↔worktree pair with nothing in - -- it, so the view follows the file to its staged pair rather than sitting on a diff - -- git no longer has. the reviewer's line comes along: it's a side swap, not a file - -- switch, and the two sides agree on line numbers now the unstaged one is empty - it("follows to the staged side when the last unstaged hunk is staged", function() + -- the winbar counts the hunks the index holds whole and in part; zero counts drop out + it("tallies staged and partial hunks in the diff winbar", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") git(root, "commit", "-q", "-am", "ten lines") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") -- staged: a hunk at line 1 + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") git(root, "add", "a.lua") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- unstaged: a hunk at line 10 - + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - vim.api.nvim_win_set_cursor(0, { 10, 2 }) - - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) - assert.are.equal(1, #v.model.hunks) -- just the line-10 change - vim.api.nvim_set_current_win(p.origin_win) + local p, v = open_panel() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local mixed = winbar_of(v) v:stage_hunk() + local whole = winbar_of(v) + v:unstage_hunk() + local none = winbar_of(v) + p:close() + assert.is_truthy(mixed:find(" 1 partial · hunk 1/1 ", 1, true)) + assert.is_truthy(whole:find(" hunk 1/1 ", 1, true)) + assert.is_nil(whole:find("staged ·", 1, true)) -- every hunk staged: no tally + assert.is_truthy(none:find(" hunk 1/1 ", 1, true)) + assert.is_nil(none:find("staged", 1, true)) + assert.is_nil(none:find("partial", 1, true)) + end) + + -- the winbar as drawn on the screen grid rather than re-evaluated, so a stale one shows + local function drawn_winbar(v) + local win = v.columns[#v.columns].winid + vim.cmd("redraw") + local row, col = unpack(vim.api.nvim_win_get_position(win)) + local chars = {} + for c = col + 1, col + vim.api.nvim_win_get_width(win) do + chars[#chars + 1] = vim.fn.screenstring(row + 1, c) + end + return table.concat(chars) + end - -- the staged pair, carrying both edits, with every hunk marked staged - assert.are.equal("staged", v.staging.initial) - assert.are.equal(2, #v.model.hunks) -- the line-1 and line-10 changes together - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) - - -- and the cursor held line 10 rather than snapping to the first hunk + it("redraws the winbar tally as soon as a hunk is unstaged", function() + partly_staged_repo() + local p, v = open_panel() local col = v.columns[#v.columns] - local row, ccol = unpack(vim.api.nvim_win_get_cursor(col.winid)) - assert.are.equal(10, col.map.lines[row].new) - assert.are.equal(2, ccol) + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local before = drawn_winbar(v) + v:unstage_hunk() + local after = drawn_winbar(v) p:close() + assert.is_truthy(before:find("2 staged", 1, true)) + assert.is_truthy(after:find("1 staged", 1, true)) end) - it("keeps the folds the user opened when staging follows to the staged side", function() + it("tallies staged hunks beside unstaged ones", function() + partly_staged_repo() + local p = open_panel() + local text = winbar_of(view_in_origin(p)) + p:close() + assert.is_truthy(text:find(" 2 staged · hunk %d/3 ")) + assert.is_nil(text:find("partial", 1, true)) + end) + + -- on a two-row file X refuses a staged hunk, since reverting only the worktree copy + -- would leave index and worktree differing the opposite way round. a union row has + -- both sides in one diff, so it takes them both and the refusal doesn't apply + it("X on a staged union hunk drops it from the index and the worktree", function() local root = fresh_repo() - local lines = {} - for i = 1, 20 do - lines[i] = tostring(i) - end - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") - git(root, "commit", "-q", "-am", "20 lines") - lines[1] = "1x" - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") - git(root, "add", "a.lua") -- staged: a hunk at line 1 - lines[20] = "20x" - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") -- unstaged: line 20 + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - v:set_context(1) - open_fold_at(v, "10") + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + confirming(1, function() + v:revert_hunk() + end) - vim.api.nvim_set_current_win(p.origin_win) - vim.api.nvim_win_set_cursor(0, { row_at(v, "20x"), 0 }) - v:stage_hunk() - assert.are.equal("staged", v.staging.initial) - assert.are.equal(-1, fold_closed_at(v, "10")) + -- the line-1 change is gone from both sides; the line-10 one is untouched + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + assert.are.equal("1\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n", worktree(root, "a.lua")) + assert.are.equal(1, #v.model.hunks) + assert.are.equal("unstaged", v:_hunk_state(1)) -- the marks followed the splice p:close() end) - -- only the unstaged side follows. emptying the staged one stays put, which is what - -- lets s put the hunk straight back where it was rather than ping-ponging the view - it("stays on the staged pair when its last staged hunk is unstaged", function() + -- unstaging a fully staged file's last hunk leaves the diff where it is, which is + -- what lets s put the hunk straight back + it("unstages and re-stages a fully staged file in place", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") git(root, "add", "a.lua") -- staged outright: the diff opens on HEAD↔index vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 1), 0 }) - v:unstage_hunk() -- the staged side is empty now, but the view holds + v:unstage_hunk() assert.are.equal(V1, indexed(root, "a.lua")) -- back to HEAD - assert.are.equal("staged", v.staging.initial) assert.are.equal("a.lua", v.model.path) - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) - v:stage_hunk() -- and s puts it back in place, on the same pair - assert.is_true(v.staged_hunks[1]) + v:stage_hunk() -- and s puts it back in place + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) p:close() end) @@ -1865,9 +4977,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() -- edit only the worktree (status stays " M"): the content-aware signature still -- moves, so the diff re-sources where the old HEAD+status signature would miss it @@ -1881,25 +4991,23 @@ describe(":Differ diff hunk staging", function() p:close() end) - it("follows a file to its staged side when staged wholesale outside differ", function() + it("re-marks a file staged wholesale outside differ", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified, unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- viewing the unstaged side + local p, v = open_panel() + assert.are.equal("unstaged", v:_hunk_state(1)) git(root, "add", "a.lua") -- stage the whole file in "lazygit" vim.api.nvim_exec_autocmds("FocusGained", { group = p.augroup }) vim.wait(200, function() - return v.staging.initial == "staged" + return v:_hunk_state(1) == "staged" end) - -- the diff followed the file to its staged side rather than going blank - assert.are.equal("staged", v.staging.initial) - assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- HEAD↔index + -- the same diff, re-read with the hunk now marked staged + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) p:close() end) @@ -1911,9 +5019,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "a\nINS1\nINS2\nb\nc\nD\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -1942,16 +5048,14 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1\nA1\nA2\n2\n3\n4\n5\n6\nB1\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 2), 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[2]) - assert.is_falsy(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("unstaged", v:_hunk_state(1)) -- B1 after line 6, where the hunk actually is; not after line 8, where the -- worktree's own numbering would put it @@ -1971,51 +5075,28 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1\nINS1\nINS2\n2\n3\n4\n7\n8\n9\nZ\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) for _, n in ipairs({ 1, 2 }) do vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, n), 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[n]) + assert.are.equal("staged", v:_hunk_state(n)) end assert.are.equal("1\nINS1\nINS2\n2\n3\n4\n7\n8\n9\n10\n", indexed(root, "a.lua")) -- put the deletion back: lines 5 and 6 belong after 4, not earlier vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 2), 0 }) v:unstage_hunk() - assert.is_false(v.staged_hunks[2]) + assert.are.equal("unstaged", v:_hunk_state(2)) assert.are.equal("1\nINS1\nINS2\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) p:close() end) -- the panel row for `path`, optionally pinned to its staged or unstaged side - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or m.entry.staged == staged) - then - return i - end - end - end -- answer the revert confirm with `choice` for the duration of `fn` - local function confirming(choice, fn) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return choice - end - local ok, err = pcall(fn) - vim.fn.confirm = orig - assert(ok, err) - end - it("reverts one hunk from the worktree, leaving the others and the index", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") @@ -2023,9 +5104,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2048,9 +5127,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -2079,9 +5156,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", table.concat(edited, "\n") .. "\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) local asked_on = v.model @@ -2130,9 +5205,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "local z = 99\nreturn z\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -2172,10 +5245,8 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") -- both edits staged; the worktree matches the index vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2191,32 +5262,29 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- reverting only the worktree copy of a hunk already pushed to the index would - -- leave the two differing the other way round, so the key refuses instead - it("refuses to revert a hunk staged during the session", function() + -- the diff has both sides of a hunk staged during the session, so X takes it out of + -- the index and the worktree together rather than leaving them differing + it("reverts a hunk staged during the session from both sides", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") git(root, "commit", "-q", "-am", "8 lines") - -- two hunks, so staging one leaves the unstaged pair live and the view frozen on - -- it: a marked hunk on a worktree diff is exactly the state the guard is for write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[1]) - assert.are.equal("WORKTREE", v.model.new_rev) -- still the unstaged pair + assert.are.equal("staged", v:_hunk_state(1)) - confirming(1, function() -- would say yes, but it never gets asked + confirming(1, function() v:revert_hunk() end) - assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8x\n", worktree(root, "a.lua")) - assert.are.equal(2, #v.model.hunks) + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + assert.are.equal("1\n2\n3\n4\n5\n6\n7\n8x\n", worktree(root, "a.lua")) + assert.are.equal(1, #v.model.hunks) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -2227,9 +5295,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/new.lua", "fresh\n") -- untracked vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("deletes the file", v.staging.revert_label) vim.api.nvim_set_current_win(p.origin_win) @@ -2257,9 +5323,7 @@ describe(":Differ diff hunk staging", function() git(root, "add", "new.lua") -- staged add: "A" vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -2283,9 +5347,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/gone.lua", "temporary\n") -- untracked, about to go vim.cmd.edit(root .. "/gone.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("gone.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -2312,9 +5374,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2343,9 +5403,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua has one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) assert.are.equal(1, #v.model.hunks) @@ -2369,9 +5427,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- the only change in the repo vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(1, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2395,8 +5451,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") local invoked_from = vim.api.nvim_get_current_tabpage() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.is_false(invoked_from == vim.api.nvim_get_current_tabpage()) -- own tab local v = view_in_origin(p) @@ -2416,9 +5471,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(v:is_open()) git(root, "commit", "-q", "-am", "committed elsewhere") @@ -2435,11 +5488,9 @@ describe(":Differ diff hunk staging", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") write(root .. "/b.lua", "kept\n") -- a second change, untracked - vim.cmd.edit(root .. "/a.lua") - - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + vim.cmd.edit(root .. "/a.lua") + + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -2465,8 +5516,7 @@ describe(":Differ diff hunk staging", function() os.remove(root .. "/b.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() local v = view_in_origin(p) @@ -2492,8 +5542,7 @@ describe(":Differ diff hunk staging", function() git(root, "rm", "-q", "b.lua") -- deletion staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", true), 0 }) p:select() local v = view_in_origin(p) @@ -2520,8 +5569,7 @@ describe(":Differ diff hunk staging", function() os.remove(root .. "/b.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() local v = view_in_origin(p) @@ -2538,7 +5586,7 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- the removal is in the index, and the view followed the file to its staged side assert.are.equal("", git(root, "ls-files", "--", "b.lua")) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(0, vim.fn.filereadable(root .. "/b.lua")) -- still gone from disk vim.api.nvim_set_current_win(p.origin_win) @@ -2555,9 +5603,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1\nz2\n") -- untracked: staged wholesale, not by hunk vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) _G.notifs = {} @@ -2567,7 +5613,7 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() os.remove(root .. "/.git/index.lock") -- dropped before the asserts, not after - assert.is_falsy(v.staged_hunks[1]) -- no mark for something git didn't stage + assert.are.equal("unstaged", v:_hunk_state(1)) -- no mark for something git didn't stage assert.are.equal("", git(root, "ls-files", "--", "z.lua")) -- and it really didn't assert.is_truthy( (_G.notifs[1] and _G.notifs[1].msg or ""):find("stage failed", 1, true), @@ -2584,9 +5630,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") -- the real file is open in a buffer local filebuf = vim.api.nvim_get_current_buf() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) confirming(1, function() @@ -2607,9 +5651,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) -- stage the first, then revert the second: the mark has to follow hunk 1 @@ -2631,8 +5673,8 @@ describe(":Differ diff hunk staging", function() end) assert.are.equal(2, #v.model.hunks) - assert.is_true(v.staged_hunks[1]) -- still the 1 -> 1x hunk - assert.is_nil(v.staged_hunks[2]) -- what was hunk 3, unstaged, renumbered down + assert.are.equal("staged", v:_hunk_state(1)) -- still the 1 -> 1x hunk + assert.are.equal("unstaged", v:_hunk_state(2)) -- what was hunk 3, unstaged, renumbered down assert.are.same({ "9x" }, v.model.hunks[2].new_lines) p:close() end) @@ -2644,9 +5686,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1\n2\n3\nX\n5\n6\n") -- only line 4 changes vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local cur = vim.api.nvim_win_get_cursor(p.origin_win)[1] assert.is_not_nil(v.columns[1].map.lines[cur].hunk) -- on a hunk, not context assert.are.equal(v:_first_review_line(v.columns[1]), cur) @@ -2660,9 +5700,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- hunks at lines 1 and 8 vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(1, v:_first_review_line(v.columns[1])) -- both unstaged -> hunk 1 vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -2671,7 +5709,7 @@ describe(":Differ diff hunk staging", function() p:close() end) - it("df on a staged diff unstages the file and re-sources to its worktree view", function() + it("df on a fully staged file edits the worktree and leaves the index alone", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n") git(root, "commit", "-q", "-am", "base") @@ -2679,16 +5717,35 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") -- and stage it (fully staged: worktree == index) vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) + local p, v = open_panel() + assert.are.equal("WORKTREE", v.model.new_rev) + + v:edit_file() + assert.is_truthy(v.edit_win) + local staged = git(root, "diff", "--cached", "--name-only") or "" + assert.is_truthy(staged:find("a.lua", 1, true)) -- still staged + p:close() + end) + + -- a file deleted in the worktree has nothing on disk to open + it("df on a worktree deletion refuses rather than opening a missing file", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") -- MD: an edit staged, the file then deleted + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + + git_src.panel({ rev = {} }) local p = Panel.current() + assert.is_true(p:focus_file("a.lua")) + p:select(true) local v = view_in_origin(p) - assert.are.equal("INDEX", v.model.new_rev) -- opens on the staged HEAD<->index diff - v:edit_file() -- flow C: unstage + re-source to index<->worktree - assert.are.equal("WORKTREE", v.model.new_rev) -- the diff now reflects the worktree - assert.is_truthy(v.edit_win) -- and the editable window opened - local staged = git(root, "diff", "--cached", "--name-only") or "" - assert.is_nil(staged:find("a.lua", 1, true)) -- a.lua is no longer staged + _G.notifs = {} + v:edit_file() + assert.is_nil(v.edit_win) + assert.is_truthy(_G.notifs[#_G.notifs].msg:find("a.lua is not on disk", 1, true)) p:close() end) @@ -2705,9 +5762,7 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:set_context(1) assert.are_not.equal(-1, fold_closed_at(v, "10")) open_fold_at(v, "10") @@ -2730,9 +5785,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") vim.cmd.edit(root .. "/z.lua") -- open on the last file in the list - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) _G.notifs = {} @@ -2761,9 +5814,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) -- the staged a.lua row renders first, so the wrap meets it before b.lua assert.is_true(file_line(p, "a.lua", true) < file_line(p, "b.lua", false)) @@ -2785,9 +5836,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- the only unstaged file vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) local hunks_before = #v.model.hunks @@ -2808,14 +5857,12 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) assert.is_true(p:step_review("prev", true, true)) assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) p:close() end) @@ -2831,9 +5878,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- z.lua: three hunks vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) assert.are.equal(3, #v.model.hunks) @@ -2844,9 +5889,9 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- advance to hunk 3 assert.are.equal(hunk_line(v, 3), vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- stage hunk 3 - assert.is_true(v.staged_hunks[2]) - assert.is_true(v.staged_hunks[3]) - assert.is_falsy(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("staged", v:_hunk_state(3)) + assert.are.equal("unstaged", v:_hunk_state(1)) -- at the bottom of the file: back round to the hunk left behind, not off to -- another file, and without re-sourcing the frozen diff @@ -2862,13 +5907,13 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- stage it: z.lua is done, and follows to its staged side assert.are.equal(worktree(root, "z.lua"), indexed(root, "z.lua")) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) -- only now does the review leave, for the file it never opened vim.api.nvim_set_current_win(p.origin_win) v:stage_hunk() assert.are.equal("a.lua", v.model.path) - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -2880,9 +5925,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- the only file, three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2903,7 +5946,7 @@ describe(":Differ diff hunk staging", function() -- and it stages there, finishing the file v:stage_hunk() - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) p:close() end) @@ -2917,9 +5960,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- the only file, three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) -- stage 1 and 2, leaving 3 alone: the unstaged side survives, so the view stays @@ -2929,13 +5970,13 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- stage hunk 1 v:stage_hunk() -- advance to hunk 2 v:stage_hunk() -- stage hunk 2 - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) - assert.is_falsy(v.staged_hunks[3]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("unstaged", v:_hunk_state(3)) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 1), 0 }) v:unstage_hunk() -- unstage hunk 1, in place - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) _G.notifs = {} v:unstage_hunk() -- nothing staged behind it: round to hunk 2 @@ -2958,23 +5999,21 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) -- opened from new-file line 1 (the "1" -> "1x" change), so it lands on that -- line's new-side row (row 2, under the deleted old "1"), not the hunk's top assert.are.equal(2, vim.api.nvim_win_get_cursor(p.origin_win)[1]) -- on hunk 1 v:stage_hunk() -- stage hunk 1; cursor stays put, marked - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(2, vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- second s: advance to hunk 2 (buffer line 9) assert.are.equal(9, vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- stage hunk 2 - assert.is_true(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(2)) v:stage_hunk() -- second s on the last hunk: step to the next file assert.are.equal("z.lua", v.model.path) @@ -2993,14 +6032,12 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua", "z.lua") -- stage both: a staged (HEAD↔index) review vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:step_file("next") -- move forward to z.lua (the last file) assert.are.equal("z.lua", v.model.path) v:unstage_hunk() -- unstage z.lua's hunk; cursor stays, now unstaged - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) v:unstage_hunk() -- second u: no earlier hunk -> step back to a.lua's last hunk assert.are.equal("a.lua", v.model.path) @@ -3016,20 +6053,18 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "a\nINS1\nINS2\nb\nc\nD\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) v:stage_all() -- both hunks into the index in one go assert.are.equal("a\nINS1\nINS2\nb\nc\nD\n", indexed(root, "a.lua")) - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) v:unstage_all() -- back out of the index entirely assert.are.equal("a\nb\nc\nd\n", indexed(root, "a.lua")) -- == HEAD - assert.is_false(v.staged_hunks[1]) - assert.is_false(v.staged_hunks[2]) + assert.are.equal("unstaged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -3043,9 +6078,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) v:stage_all() -- a.lua now fully staged; we stay put @@ -3069,9 +6102,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: unstaged, last in the list vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) v:stage_all() -- z.lua fully staged; the view follows to its staged side @@ -3079,7 +6110,7 @@ describe(":Differ diff hunk staging", function() v:stage_all() -- nothing left here: round the end and past the staged a.lua assert.are.equal("b.lua", v.model.path) - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -3096,14 +6127,12 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk, unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) v:unstage_all() -- nothing staged here: back to a.lua, on its last staged hunk assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.is_not_nil(v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]].hunk) p:close() end) @@ -3118,9 +6147,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk, left unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) -- u / U at the first file with nothing staged: stay put, no wrap to the last @@ -3157,9 +6184,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) v:goto_hunk("next") -- past a.lua's only (last) hunk: flow into z.lua @@ -3229,103 +6254,448 @@ describe(":Differ diff hunk staging", function() local v = view_in_origin(p) assert.are.equal("a.lua", v.model.path) - vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) + vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) + _G.notifs = {} + v:goto_hunk("next") + assert.are.equal("z.lua", v.model.path) + assert.are.equal(0, #_G.notifs) + p:close() + end) + + it("]c flowing forward lands on the new file's first hunk, not line 1", function() + -- z.lua mirrors a file with a long header before its first real change: 30 + -- lines of context, a small hunk, more context, then a second hunk near the + -- end. a hunk-at-line-1 fixture can't tell "landed on the first hunk" apart + -- from "cursor just carried over from wherever it was" -- this one can. + local root = fresh_repo() + local base = {} + for i = 1, 30 do + base[#base + 1] = "ctx" .. i + end + base[#base + 1] = "OLD_A" + for i = 31, 60 do + base[#base + 1] = "ctx" .. i + end + base[#base + 1] = "OLD_B" + write(root .. "/z.lua", table.concat(base, "\n") .. "\n") + git(root, "add", "z.lua") + git(root, "commit", "-q", "-am", "two files") + + local changed = vim.deepcopy(base) + changed[31] = "NEW_A" + changed[#changed] = "NEW_B" + write(root .. "/a.lua", "1x\n2\n") -- a.lua: one hunk near the top + write(root .. "/z.lua", table.concat(changed, "\n") .. "\n") -- z.lua: two hunks + vim.cmd.edit(root .. "/a.lua") + + local p, v = open_panel() + assert.are.equal("a.lua", v.model.path) + + v:goto_hunk("next") -- past a.lua's only hunk: flow into z.lua + assert.are.equal("z.lua", v.model.path) + local first = v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]] + assert.are.equal(1, first and first.hunk) -- z.lua's first hunk, not a leftover cursor row + + v:goto_hunk("next") -- z.lua's second hunk, not a skip out to a (nonexistent) next file + assert.are.equal("z.lua", v.model.path) + local second = v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]] + assert.are.equal(2, second and second.hunk) + p:close() + end) + + it("binds s/u/S/U in the diff window for a worktree source, not a rev-pair", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + local lhs = keymaps(view_in_origin(p).columns[1].bufnr) + for _, k in ipairs({ "s", "u", "S", "U", "X" }) do + assert.is_true(lhs[k]) + end + p:close() + + git(root, "commit", "-q", "-am", "edit") -- now a clean worktree + git_src.panel({ rev = "HEAD~1..HEAD", open_first = true }) + local p2 = Panel.current() + local lhs2 = keymaps(view_in_origin(p2).columns[1].bufnr) + assert.is_nil(lhs2["s"]) -- rev-pair sources aren't stageable + assert.is_nil(lhs2["u"]) + assert.is_nil(lhs2["S"]) + assert.is_nil(lhs2["U"]) + assert.is_nil(lhs2["X"]) -- nor revertable: there's no worktree side to write + p2:close() + end) + + it("binds df only on a worktree-vs-base source, not a `↔worktree` open", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + + -- default `:Differ` is HEAD↔worktree (uncommitted): df is bound + local p = open_panel() + assert.is_true(keymaps(view_in_origin(p).columns[1].bufnr)["df"]) + p:close() + + -- commit so the worktree is clean, then `:Differ HEAD~1` is ↔worktree: + -- it folds in committed history, so edit-in-review must be off (df unbound) + git(root, "commit", "-q", "-am", "edit") + git_src.panel({ rev = "HEAD~1", open_first = true }) + local p2 = Panel.current() + local v2 = view_in_origin(p2) + assert.are.equal("WORKTREE", v2.model.new_rev) -- new side is the worktree... + assert.are.equal("HEAD~1", v2.model.old_rev) -- ...but the base is an older rev + assert.is_nil(keymaps(v2.columns[1].bufnr)["df"]) + assert.is_false(v2:_editable_source()) + p2:close() + end) + + -- HEAD's "x x" pair loses one line in the worktree, which the union shows under the + -- first hunk: the second hunk reads partial, but neither pair holds anything of it + -- on its own, so s has no text to write and has to say where the change can be taken + local function stuck_repo() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\nx\nx\n5\n") + git(root, "commit", "-q", "-am", "five") + write(root .. "/a.lua", "1\nx\nx\n5x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2x\nx\n5x\n") + vim.cmd.edit(root .. "/a.lua") + return root + end + + it("refuses a hunk neither pair holds alone rather than rewriting the index", function() + local root = stuck_repo() + local p, v = open_panel() + local before = indexed(root, "a.lua") + local stuck + for i = 1, #v.model.hunks do + if v:_hunk_state(i) == "partial" then + stuck = i + end + end + assert.is_not_nil(stuck) + local changed, refused = v:_apply_hunk(stuck, true) + assert.is_false(changed) + assert.is_true(refused) + assert.are.equal(before, indexed(root, "a.lua")) -- no identical blob written + local said = _G.notifs[#_G.notifs].msg + assert.is_truthy(said:find("can't be staged on its own here", 1, true)) + assert.is_truthy(said:find("dw", 1, true)) + p:close() + end) + + it("steps s off a hunk it refused instead of parking on it", function() + local root = stuck_repo() + local p, v = open_panel() + local stuck + for i = 1, #v.model.hunks do + if v:_hunk_state(i) == "partial" then + stuck = i + end + end + vim.api.nvim_win_set_cursor(v.columns[1].winid, { hunk_line(v, stuck), 0 }) + v:stage_hunk() -- refuses, and the walk carries on past it + assert.are_not.equal(stuck, v:_hunk_index_under_cursor()) + assert.is_true(v.stuck.staged[stuck]) + -- the hunk it wrapped to does move, and takes the refused one with it + v:stage_hunk() + assert.are.equal(indexed(root, "a.lua"), worktree(root, "a.lua")) + p:close() + end) + + -- staged, then the worktree puts the committed content back, so only dw reaches it + local function put_back(root, paths) + for _, path in ipairs(paths) do + vim.fn.mkdir(vim.fs.dirname(root .. "/" .. path), "p") + write(root .. "/" .. path, "one\n") + git(root, "add", path) + end + git(root, "commit", "-q", "-m", "leftovers") + for _, path in ipairs(paths) do + write(root .. "/" .. path, "two\n") + git(root, "add", path) + write(root .. "/" .. path, "one\n") + end + end + + -- press s until a notice matches `want`, letting scheduled hand-backs run between + local function press_s(p, want) + for _ = 1, 30 do + view_in_origin(p):stage_hunk() + vim.wait(20) + local said = (_G.notifs[#_G.notifs] or {}).msg + if said and said:find(want) then + return said + end + end + end + + -- a.lua's one hunk to walk past, then s until the walk stops on a local view + local function walk(root) + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + _G.notifs = {} + return p, press_s(p, "has local changes: dw shows them$") + end + + -- the same walk, leaving each file it stops on with ]f, to its last word + local function walk_leaving(root) + local p = walk(root) + local said + for _ = 1, 10 do + view_in_origin(p):step_file("next") + _G.notifs = {} + said = press_s(p, "^differ: nothing left") + or press_s(p, "has local changes: dw shows them$") + if said:find("^differ: nothing left") then + break + end + end + p:close() + return said + end + + it("stops s on a file whose local view has something left", function() + local root = fresh_repo() + put_back(root, { "b.lua" }) + local p, said = walk(root) + view_in_origin(p):stage_hunk() + local again, path = _G.notifs[#_G.notifs].msg, view_in_origin(p).model.path + p:close() + assert.are.equal("differ: b.lua has local changes: dw shows them", said) + assert.are.equal(said, again) + assert.are.equal("b.lua", path) + end) + + it("walks on once dw has finished the file it stopped on", function() + local root = fresh_repo() + put_back(root, { "b.lua", "c.lua" }) + local p = walk(root) + view_in_origin(p):toggle_local() + _G.notifs = {} + local stop = press_s(p, "has local changes: dw shows them$") + view_in_origin(p):toggle_local() + _G.notifs = {} + local last = press_s(p, "^differ: nothing left") + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("differ: c.lua has local changes: dw shows them", stop) + assert.are.equal("differ: nothing left to stage", last) + assert.are.equal("M a.lua\n", status) + end) + + it("names a file ]f left the walk stopped on in its last word", function() + local root = fresh_repo() + put_back(root, { "b.lua" }) + assert.are.equal( + "differ: nothing left to stage (b.lua has local changes: dw shows them)", + walk_leaving(root) + ) + end) + + -- c.lua moved to d.lua with an edit, all staged: U leaves the move staged + it("ends the u walk without naming rows only a move keeps staged", function() + local root = fresh_repo() + write(root .. "/c.lua", twelve({})) + git(root, "add", "c.lua") + git(root, "commit", "-q", "-m", "c") + git(root, "mv", "c.lua", "d.lua") + write(root .. "/d.lua", twelve({ [3] = "3x" })) + git(root, "add", "d.lua") + vim.cmd.edit(root .. "/d.lua") + local p = open_panel() + _G.notifs = {} + local said + for _ = 1, 10 do + view_in_origin(p):unstage_all() + vim.wait(20) + said = (_G.notifs[#_G.notifs] or {}).msg + if said and said:find("^differ: nothing left") then + break + end + end + p:close() + assert.are.equal("differ: nothing left to unstage", said) + end) + + it("stays on the file when U refuses a drawing the file has since changed", function() + local root = fresh_repo() + write(root .. "/a.lua", "one\n") + git(root, "add", "a.lua") + write(root .. "/b.lua", "b\n") + git(root, "add", "b.lua") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + write(root .. "/a.lua", "one\ntwo\n") -- before the watcher has re-read it + _G.notifs = {} + v:unstage_all() + vim.wait(20) + local path = view_in_origin(p).model.path + local said = vim.tbl_map(function(n) + return n.msg + end, _G.notifs) + p:close() + assert.are.equal("a.lua", path) + assert.is_nil(table.concat(said, "\n"):find("local changes", 1, true)) + end) + + -- b, c and d staged and then put back on disk: u on b empties its row + it("]f after the open row leaves the list opens the row that took its place", function() + local root = fresh_repo() + for _, f in ipairs({ "b.lua", "c.lua", "d.lua" }) do + write(root .. "/" .. f, "one\n") + end + git(root, "add", "b.lua", "c.lua", "d.lua") + git(root, "commit", "-q", "-m", "bcd") + for _, f in ipairs({ "b.lua", "c.lua", "d.lua" }) do + write(root .. "/" .. f, "two\n") + git(root, "add", f) + write(root .. "/" .. f, "one\n") + end + vim.cmd.edit(root .. "/b.lua") + local p, v = open_panel() + confirming(1, function() + v:unstage_hunk() + end) + vim.wait(20) + view_in_origin(p):step_file("next") + local path = view_in_origin(p).model.path + p:close() + assert.are.equal("c.lua", path) + end) + + it("ends the walk once ]f has left the only file it stops on", function() + local root = fresh_repo() + put_back(root, { "b.lua" }) + vim.cmd.edit(root .. "/b.lua") + local p = open_panel() _G.notifs = {} - v:goto_hunk("next") - assert.are.equal("z.lua", v.model.path) - assert.are.equal(0, #_G.notifs) + local stop = press_s(p, "has local changes: dw shows them$") + view_in_origin(p):step_file("next") + _G.notifs = {} + local last = press_s(p, "^differ: nothing left") p:close() + assert.are.equal("differ: b.lua has local changes: dw shows them", stop) + assert.are.equal( + "differ: nothing left to stage (b.lua has local changes: dw shows them)", + last + ) end) - it("]c flowing forward lands on the new file's first hunk, not line 1", function() - -- z.lua mirrors a file with a long header before its first real change: 30 - -- lines of context, a small hunk, more context, then a second hunk near the - -- end. a hunk-at-line-1 fixture can't tell "landed on the first hunk" apart - -- from "cursor just carried over from wherever it was" -- this one can. + it("doesn't stop s in the commit preview, which has no local view", function() local root = fresh_repo() - local base = {} - for i = 1, 30 do - base[#base + 1] = "ctx" .. i - end - base[#base + 1] = "OLD_A" - for i = 31, 60 do - base[#base + 1] = "ctx" .. i + put_back(root, { "b.lua" }) + vim.cmd.edit(root .. "/b.lua") + local p, v = open_panel() + v:toggle_commit_preview() + _G.notifs = {} + for _ = 1, 3 do + view_in_origin(p):stage_hunk() + vim.wait(20) end - base[#base + 1] = "OLD_B" - write(root .. "/z.lua", table.concat(base, "\n") .. "\n") - git(root, "add", "z.lua") - git(root, "commit", "-q", "-am", "two files") + local stops = vim.tbl_filter(function(n) + return n.msg:find("has local changes: dw shows them$") ~= nil + end, _G.notifs) + p:close() + assert.are.same({}, stops) + end) - local changed = vim.deepcopy(base) - changed[31] = "NEW_A" - changed[#changed] = "NEW_B" - write(root .. "/a.lua", "1x\n2\n") -- a.lua: one hunk near the top - write(root .. "/z.lua", table.concat(changed, "\n") .. "\n") -- z.lua: two hunks - vim.cmd.edit(root .. "/a.lua") + it("names rows left with a local view while they fit and counts the rest", function() + local root = fresh_repo() + local paths = { "b.lua", "c.lua" } + for i = 1, 3 do + paths[#paths + 1] = ("z_leftover_with_a_rather_long_name_%d.lua"):format(i) + end + put_back(root, paths) + local said = walk_leaving(root) + assert.is_truthy( + said:find( + "^differ: nothing left to stage %(b.lua, c.lua.* %+%d+ have local changes: dw shows them%)$" + ), + said + ) + end) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("a.lua", v.model.path) + it("names a row left with a local view whole even when it alone is past the width", function() + local root = fresh_repo() + local long = ("x"):rep(80) .. ".lua" + put_back(root, { long }) + local want = ("differ: nothing left to stage (%s has local changes: dw shows them)"):format( + long + ) + assert.are.equal(want, walk_leaving(root)) + end) - v:goto_hunk("next") -- past a.lua's only hunk: flow into z.lua - assert.are.equal("z.lua", v.model.path) - local first = v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]] - assert.are.equal(1, first and first.hunk) -- z.lua's first hunk, not a leftover cursor row + it("names rows left with a local view by their repo paths", function() + local root = fresh_repo() + put_back(root, { "x/b.lua", "y/b.lua" }) + local want = + "differ: nothing left to stage (x/b.lua, y/b.lua have local changes: dw shows them)" + assert.are.equal(want, walk_leaving(root)) + end) - v:goto_hunk("next") -- z.lua's second hunk, not a skip out to a (nonexistent) next file - assert.are.equal("z.lua", v.model.path) - local second = v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]] - assert.are.equal(2, second and second.hunk) + -- b.lua's content is staged and its index entry carries a mode the worktree doesn't: + -- the walk can't take that, and dw opens on the mode change + it("stops s on a row left with only a mode change", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.lua", "two\n") + git(root, "add", "b.lua") + local sha = git(root, "rev-parse", ":0:b.lua"):gsub("%s+$", "") + git(root, "update-index", "--cacheinfo", "100755," .. sha .. ",b.lua") + local p, said = walk(root) + local v = view_in_origin(p) + v:toggle_local() + local notice = v.model.notice p:close() + assert.are.equal("differ: b.lua has local changes: dw shows them", said) + assert.is_truthy(notice:find("^Mode changed"), notice) end) - it("binds s/u/S/U in the diff window for a worktree source, not a rev-pair", function() + -- an AD: the whole change is HEAD↔index, which can't show the file being deleted + it("stages a staged add's deletion from its local view", function() local root = fresh_repo() - write(root .. "/a.lua", "local x = 2\nreturn x\n") + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local lhs = keymaps(view_in_origin(p).columns[1].bufnr) - for _, k in ipairs({ "s", "u", "S", "U", "X" }) do - assert.is_true(lhs[k]) - end + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + v:toggle_local() + local badge, hunks = v.staging.badge, #v.model.hunks + local prompt = confirming(1, function() + v:stage_hunk() + end) + local status = git(root, "status", "--porcelain=v1") p:close() - - git(root, "commit", "-q", "-am", "edit") -- now a clean worktree - git_src.panel({ rev = "HEAD~1..HEAD", open_first = true }) - local p2 = Panel.current() - local lhs2 = keymaps(view_in_origin(p2).columns[1].bufnr) - assert.is_nil(lhs2["s"]) -- rev-pair sources aren't stageable - assert.is_nil(lhs2["u"]) - assert.is_nil(lhs2["S"]) - assert.is_nil(lhs2["U"]) - assert.is_nil(lhs2["X"]) -- nor revertable: there's no worktree side to write - p2:close() + assert.are.equal("Stage the deletion of c.lua? The staged file will be lost.", prompt) + assert.are.equal("LOCAL", badge) + assert.are.equal(1, hunks) -- the index's lines, all going + assert.are.equal("", status) end) - it("binds df only on a worktree-vs-base source, not a `↔worktree` open", function() + it("asks before s stages an edit's deletion from its view", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") - vim.cmd.edit(root .. "/a.lua") - - -- default `:Differ` is HEAD↔worktree (uncommitted): df is bound - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - assert.is_true(keymaps(view_in_origin(p).columns[1].bufnr)["df"]) + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") -- MD: the index alone holds the edit + write(root .. "/z.lua", "z\n") + vim.cmd.edit(root .. "/z.lua") + local p = open_panel() + assert.is_true(p:goto_path("a.lua", true)) + local v = view_in_origin(p) + local staged = git(root, "rev-parse", ":a.lua") + local prompt = confirming(2, function() + v:stage_hunk() + end) + local kept = git(root, "rev-parse", ":a.lua") p:close() - - -- commit so the worktree is clean, then `:Differ HEAD~1` is ↔worktree: - -- it folds in committed history, so edit-in-review must be off (df unbound) - git(root, "commit", "-q", "-am", "edit") - git_src.panel({ rev = "HEAD~1", open_first = true }) - local p2 = Panel.current() - local v2 = view_in_origin(p2) - assert.are.equal("WORKTREE", v2.model.new_rev) -- new side is the worktree... - assert.are.equal("HEAD~1", v2.model.old_rev) -- ...but the base is an older rev - assert.is_nil(keymaps(v2.columns[1].bufnr)["df"]) - assert.is_false(v2:_editable_source()) - p2:close() + assert.are.equal("Stage the deletion of a.lua? The staged file will be lost.", prompt) + assert.are.equal(staged, kept) end) end) @@ -3336,8 +6706,7 @@ describe(":Differ panel <-> diff wiring", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal(p.origin_win, vim.api.nvim_get_current_win()) -- in the diff assert.are_not.equal(p.winid, vim.api.nvim_get_current_win()) p:close() @@ -3349,8 +6718,7 @@ describe(":Differ panel <-> diff wiring", function() git(root, "commit", "-q", "-am", "8 lines") write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local lhs = {} for _, m in ipairs(vim.api.nvim_buf_get_keymap(p.bufnr, "n")) do @@ -3371,8 +6739,7 @@ describe(":Differ panel <-> diff wiring", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.winid) -- focus the panel assert.is_not_nil(require("differ").active_view()) @@ -3388,18 +6755,13 @@ describe(":Differ (open_first)", function() local Panel = require("differ.panel") -- p:select returns focus to the panel, so the View lives in the origin window - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end it("opens the panel and the first file's diff (DiffviewOpen-style)", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.is_not_nil(p) local v = view_in_origin(p) assert.is_not_nil(v) @@ -3419,8 +6781,7 @@ describe(":Differ (open_first)", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- one changed line vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local buf = vim.api.nvim_win_get_buf(p.origin_win) local dict = vim.b[buf].gitsigns_status_dict assert.is_not_nil(dict) @@ -3458,8 +6819,7 @@ describe(":Differ (open_first)", function() git(root, "add", "z.lua") -- staged -> two files in the set vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.origin_win) -- emulate cursor in the diff window local v = require("differ.view").current() local first = v.model.path @@ -3479,8 +6839,7 @@ describe(":Differ (open_first)", function() git(root, "add", "z.lua") -- staged (sorts first) vim.cmd.edit(root .. "/a.lua") -- open on a.lua, the last file - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.origin_win) local v = require("differ.view").current() assert.are.equal("a.lua", v.model.path) @@ -3504,9 +6863,7 @@ describe(":Differ (open_first)", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(p:is_open()) assert.is_true(v:is_open()) @@ -3659,15 +7016,17 @@ describe(":Differ panel (zero-hunk entries)", function() p:close() end) - it("opens a final-newline-only change on a notice", function() + it("opens a final-newline-only change on its last line", function() local root = fresh_repo() write(root .. "/a.lua", V1:sub(1, -2)) -- same content, no trailing newline vim.cmd.edit(root .. "/a.lua") local p, v = open_only_entry(root) - assert.is_not_nil(v) - assert.are.equal("Final newline removed", v.model.notice) + local hunks, notice = v.model.hunks, v.model.notice p:close() + assert.are.equal(1, #hunks) + assert.are.same(hunks[1].old_lines, hunks[1].new_lines) + assert.is_nil(notice) end) it("names a moved submodule pointer, which reads empty on both sides", function() @@ -3719,17 +7078,13 @@ end) describe(":Differ diff whole-file staging", function() local Panel = require("differ.panel") - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end -- the mode git records for `path` in the index local function index_mode(root, path) return (git(root, "ls-files", "--stage", "--", path):match("^(%d+)")) end local function staged_entry(p, path) for _, m in ipairs(p.meta) do - if m.kind == "file" and m.entry.path == path and m.entry.staged then + if m.kind == "file" and m.entry.path == path and m.entry.y == " " then return m.entry end end @@ -3744,9 +7099,7 @@ describe(":Differ diff whole-file staging", function() assert((vim.uv or vim.loop).fs_chmod(root .. "/a.lua", 493)) vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) assert.are.equal(0, #v.model.hunks) -- nothing to put a cursor on assert.is_true(v.staging.whole_file) @@ -3767,11 +7120,9 @@ describe(":Differ diff whole-file staging", function() git(root, "add", "a.lua") -- stage the mode change: the only entry is staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) - assert.is_true(v.staged_hunks[1]) -- seeded with no hunk behind it + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(1)) -- seeded with no hunk behind it assert.are.equal("100755", index_mode(root, "a.lua")) v:unstage_hunk() @@ -3786,9 +7137,7 @@ describe(":Differ diff whole-file staging", function() write(root .. "/b.lua", "other\n") -- a second entry, to catch a step away vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("new.lua", v.model.path) v:toggle_layout() -- -> split; the old column is all filler assert.are.equal(2, #v.columns) @@ -3811,9 +7160,7 @@ describe(":Differ diff whole-file staging", function() write(root .. "/new.lua", "one\n") vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(v.staging.whole_file) v:show_help() local help_buf = vim.api.nvim_win_get_buf(0) @@ -3824,58 +7171,195 @@ describe(":Differ diff whole-file staging", function() p:close() end) - -- a file with both a staged and an unstaged row: the staged row's diff is HEAD↔index - -- and shows nothing of what the unstaged row holds, but staging reads the file off - -- disk. `u` then `s` on the staged row is what reaches it, since `s` on a row already - -- staged steps to the next file rather than staging - local function round_trip_staged(root, path) - vim.cmd.edit(root .. "/" .. path) - git_src.panel({ rev = {} }) - local p = Panel.current() - assert.is_true(p:focus_file(path)) -- the staged row: it comes first - p:select(true) + -- the commit preview draws a staged mode change as a notice, so there's no hunk + -- under the cursor for X to find, and the whole-file slot is what it acts on + it("reverts a zero-hunk commit-preview row with X", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + vim.fn.system({ "chmod", "+x", root .. "/b.lua" }) + git(root, "add", "b.lua") + vim.cmd.edit(root .. "/b.lua") + + local p, v = open_panel() + v:toggle_commit_preview() + v = view_in_origin(p) + assert.are.equal("STAGED", v.staging.badge) + assert.are.equal(0, #v.model.hunks) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + assert.are.equal("100644", index_mode(root, "b.lua")) + end) + + -- the preview letters a row by its index side, so discard has to be told which + -- listing the entry came from or it reads the row as changed under the prompt + it("reverts a staged rename whose file is gone from the commit preview", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\ntwo\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "mv", "b.lua", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("c.lua", true)) + local v = view_in_origin(p) + v:toggle_commit_preview() + v = view_in_origin(p) + assert.are.equal(0, #v.model.hunks) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) + + -- a staged deletion whose file is still on disk untracked: X can't put HEAD's copy + -- back over it, and says so instead of asking first + it("refuses X on a kept deletion without prompting", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "rm", "-q", "--cached", "b.lua") + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("b.lua", true)) + local v = view_in_origin(p) + assert.is_nil(v.staging.revert) + local prompt = confirming(1, function() + v:revert_hunk() + end) + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.is_nil(prompt) + assert.is_truthy(said:find("on disk; u unstages the deletion instead", 1, true)) + end) + + -- a file swapped for a symlink has one content hunk but no line-level change to + -- take, so it stages as a file and X puts HEAD's own kind of file back + it("stages and reverts a typechange row as a whole file", function() + local root = fresh_repo() + write(root .. "/b.lua", "a plain file\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + os.remove(root .. "/b.lua") + vim.fn.system({ "ln", "-s", "a.lua", root .. "/b.lua" }) + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("b.lua", true)) local v = view_in_origin(p) assert.is_true(v.staging.whole_file) - assert.are.equal("staged", v.staging.initial) - _G.notifs = {} - v:unstage_hunk() - view_in_origin(p):stage_hunk() - return p - end + v:stage_hunk() + assert.are.equal("120000", index_mode(root, "b.lua")) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) - local function absorb_warning() - for _, n in ipairs(_G.notifs) do - if tostring(n.msg):find("including the edits you had left unstaged", 1, true) then - return n - end + -- b.lua staged as `from` over HEAD's plain file (nil for an add), then swapped on + -- disk for a symlink: an MT or AT row + local function typechanged_over_staged(from) + local root = fresh_repo() + if from then + write(root .. "/b.lua", "a plain file\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") end - return nil + write(root .. "/b.lua", "staged content\n") + git(root, "add", "b.lua") + os.remove(root .. "/b.lua") + vim.fn.system({ "ln", "-s", "a.lua", root .. "/b.lua" }) + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(Panel.current():goto_path("b.lua", true)) + return root, p, view_in_origin(p) end - it("warns when staging a whole file sweeps in the unstaged row too", function() - local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- a staged add - write(root .. "/new.lua", "one\ntwo\nthree WIP\n") -- edited after, left unstaged - - local p = round_trip_staged(root, "new.lua") + it("stages an MT row as a whole file", function() + local root, p, v = typechanged_over_staged(true) + local before = git(root, "status", "--porcelain=v1", "--", "b.lua") + assert.is_true(v.staging.whole_file) + v:stage_hunk() + p:close() + assert.are.equal("MT b.lua\n", before) + assert.are.equal("120000", index_mode(root, "b.lua")) + end) - -- the staged diff never showed the WIP line, and it is in the index now - assert.are.equal("one\ntwo\nthree WIP\n", git(root, "show", ":new.lua")) - assert.are.equal(vim.log.levels.WARN, assert(absorb_warning()).level) + it("reverts an MT row to HEAD's file", function() + local root, p, v = typechanged_over_staged(true) + local prompt = confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") p:close() + assert.is_truthy(prompt) + assert.are.equal("", status) end) - it("stays quiet when the file on disk is what the diff staged", function() - local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- staged, and the worktree matches the index + it("stages an MT row whole from its local view", function() + local root, p, v = typechanged_over_staged(true) + v:toggle_local() + v = view_in_origin(p) + assert.are.equal("LOCAL", v.staging.badge) + assert.is_true(v.staging.whole_file) + v:stage_hunk() + p:close() + assert.are.equal("120000", index_mode(root, "b.lua")) + end) - local p = round_trip_staged(root, "new.lua") + it("stages an AT row as a whole file", function() + local root, p, v = typechanged_over_staged(nil) + local before = git(root, "status", "--porcelain=v1", "--", "b.lua") + assert.is_true(v.staging.whole_file) + v:stage_hunk() + p:close() + assert.are.equal("AT b.lua\n", before) + assert.are.equal("120000", index_mode(root, "b.lua")) + end) - assert.are.equal("one\ntwo\n", git(root, "show", ":new.lua")) - assert.is_nil(absorb_warning()) + it("stages an RT row as a whole file and leaves X to the panel row", function() + local root = fresh_repo() + write(root .. "/b.lua", "a plain file\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "mv", "b.lua", "c.lua") + os.remove(root .. "/c.lua") + vim.fn.system({ "ln", "-s", "a.lua", root .. "/c.lua" }) + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + local before = git(root, "status", "--porcelain=v1", "--", "b.lua", "c.lua") + assert.is_true(Panel.current():goto_path("c.lua", true)) + local v = view_in_origin(p) + assert.is_true(v.staging.whole_file) + _G.notifs = {} + local prompt = confirming(1, function() + v:revert_hunk() + end) + local said = (_G.notifs[#_G.notifs] or {}).msg + local after_x = git(root, "status", "--porcelain=v1", "--", "b.lua", "c.lua") + v:stage_hunk() + local mode = index_mode(root, "c.lua") + local old = git(root, "ls-files", "--", "b.lua") p:close() + assert.are.equal("RT b.lua -> c.lua\n", before) + assert.is_nil(prompt) + assert.are.equal("differ: X on the panel row undoes the move", said) + assert.are.equal(before, after_x) + assert.are.equal("120000", mode) + assert.are.equal("", old) end) end) @@ -3884,11 +7368,6 @@ end) describe(":Differ diff rename staging", function() local Panel = require("differ.panel") - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end - -- a 60-line file committed at old/big.lua, then staged-renamed to new/big.lua with -- `edits` (a list of line numbers) changed on the way, so the rename carries that -- many hunks. 60 lines keeps a few edits well above git's 50% similarity threshold, @@ -3991,7 +7470,7 @@ describe(":Differ diff rename staging", function() local v = open_staged(p, "new/big.lua") assert.are.equal(3, #v.model.hunks) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.same({ true, true, true }, painted_hunks(v)) p:close() end) @@ -4036,6 +7515,28 @@ describe(":Differ diff rename staging", function() p:close() end) + -- an RM: the old path is in neither the index nor the worktree, so git add refuses it + it("stages the rest of a staged rename from the panel row without an error", function() + local root = renamed_repo({ 5 }) + local text = table.concat(vim.fn.readfile(root .. "/new/big.lua"), "\n") .. "\n" + write(root .. "/new/big.lua", (text:gsub("line 30\n", "line 30 unstaged\n"))) + vim.cmd.edit(root .. "/new/big.lua") + + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("new/big.lua")) + _G.notifs = {} + p:stage_op("stage") + local failed = false + for _, n in ipairs(_G.notifs) do + failed = failed or n.msg:find("failed", 1, true) ~= nil + end + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.is_false(failed) + assert.are.equal("R old/big.lua -> new/big.lua\n", status) + end) + it("unstages both of a rename's paths from the panel row, as the diff keys do", function() local root = renamed_repo({ 5 }) vim.cmd.edit(root .. "/new/big.lua") @@ -4052,19 +7553,6 @@ describe(":Differ diff rename staging", function() p:close() end) - -- run `fn` with confirm answering `answer`, returning the prompt it was shown - local function under_confirm(answer, fn) - local orig, prompt = vim.fn.confirm, nil - vim.fn.confirm = function(msg) - prompt = msg - return answer - end - local okay, err = pcall(fn) - vim.fn.confirm = orig - assert(okay, err) - return prompt - end - it("undoes a rename with X, restoring the old path and dropping the new", function() local root = renamed_repo({ 5, 30 }) vim.cmd.edit(root .. "/new/big.lua") @@ -4073,7 +7561,7 @@ describe(":Differ diff rename staging", function() local p = Panel.current() assert.is_true(p:focus_file("new/big.lua")) - under_confirm(1, function() + confirming(1, function() p:discard() end) @@ -4092,7 +7580,7 @@ describe(":Differ diff rename staging", function() local p = Panel.current() assert.is_true(p:focus_file("new/big.lua")) - local prompt = under_confirm(2, function() -- answer No: nothing is discarded + local prompt = confirming(2, function() -- answer No: nothing is discarded p:discard() end) @@ -4214,7 +7702,7 @@ describe("git listing errors", function() local n = last_notif() assert.are.equal(vim.log.levels.ERROR, n.level) assert.is_truthy(n.msg:find("nosuchref", 1, true)) - assert.is_nil(n.msg:find("no changes for this source", 1, true)) + assert.is_nil(n.msg:find("no changes to show", 1, true)) end) it("still reports a genuinely empty source at INFO", function() @@ -4224,7 +7712,7 @@ describe("git listing errors", function() _G.notifs = {} assert.is_nil(git_src.panel({})) local n = last_notif() - assert.are.equal("differ: no changes for this source", n.msg) + assert.are.equal("differ: no changes to show", n.msg) assert.are.equal(vim.log.levels.INFO, n.level) end) diff --git a/test/nvim/minimal_init.lua b/test/nvim/minimal_init.lua index 062b92a..35cc1b2 100644 --- a/test/nvim/minimal_init.lua +++ b/test/nvim/minimal_init.lua @@ -5,3 +5,20 @@ _G.notifs = {} vim.notify = function(msg, level) _G.notifs[#_G.notifs + 1] = { msg = msg, level = level } end + +-- a panel stages and reverts in the repo it opened on: the current file's repo, else +-- cwd's. a spec that opens one without a file of its throwaway repo focused lands on +-- the repo the suite runs from, so that fails here before any key can write to it +local suite_root = vim.fn.systemlist({ "git", "rev-parse", "--show-toplevel" })[1] +local suite_label = vim.fn.fnamemodify(suite_root, ":~") -- the form Panel.root holds +local git = require("differ.git") +local panel = git.panel +git.panel = function(opts) + local out = panel(opts) + local p = require("differ.panel").current() + if p and p.root == suite_label then + p:close() + error("a spec opened a panel on the repo the suite runs from") + end + return out +end diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index 97e06f4..b5dc157 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -164,7 +164,19 @@ describe("panel navigation", function() it("focus_first_unstaged lands on the first unstaged file, skipping Staged", function() local function se(path, staged) - return { path = path, status = "M", additions = 1, deletions = 0, staged = staged } + local x, y, review = "M", " ", "staged" + if not staged then + x, y, review = " ", "M", "unstaged" + end + return { + path = path, + status = "M", + additions = 1, + deletions = 0, + x = x, + y = y, + review = review, + } end local p, picked = panel({}, { sections = { @@ -181,7 +193,15 @@ describe("panel navigation", function() it("focus_first_unstaged falls back to the first file when all are staged", function() local function se(path) - return { path = path, status = "M", additions = 1, deletions = 0, staged = true } + return { + path = path, + status = "M", + additions = 1, + deletions = 0, + x = "M", + y = " ", + review = "staged", + } end local p, picked = panel({}, { sections = { { title = "Staged", entries = { se("a.lua"), se("b.lua") } } }, @@ -505,6 +525,29 @@ describe("panel selection identity", function() } end + -- a refused file op moved nothing, so the list mustn't reload or the hook fire: the + -- commit preview refuses s this way + it("does nothing after a file op the action refused", function() + local staged, reloaded + local actions = acts(function() + reloaded = true + end) + actions.stage = function() + return false + end + local p = panel({ fe("a.lua") }, { + actions = actions, + on_staged = function(paths) + staged = paths + end, + }) + p:open() + p:stage_op("stage") + p:close() + assert.is_nil(staged) + assert.is_nil(reloaded) + end) + -- name mode reorders the list (b.lua rises above a.lua as the tree dir collapses -- away), so restoring the cursor by line number lands it on the other file it("holds the cursor on its file across a listing toggle", function() @@ -628,6 +671,31 @@ describe("panel selection identity", function() p:close() end) + -- the walk follows the list order the open file opened on. a row that isn't in it + -- has nowhere in it to have come round from, so nothing it reaches is a wrap + it("claims no wrap stepping back from a row the remembered order doesn't hold", function() + vim.cmd("silent! only") + local function staged_row(path) + return { path = path, status = "M", additions = 1, deletions = 0, review = "staged" } + end + local p = Panel.new({ + sections = { + { title = "Staged", entries = { staged_row("a.lua"), staged_row("z.lua") } }, + }, + on_select = function() + return true + end, + }) + p:open() + p:goto_path("z.lua") + p.walk_order = { "gone.lua" } -- the order was taken before this row was listed + local before = #_G.notifs + assert.is_true(p:step_review("prev", true, true)) + local said = #_G.notifs > before and _G.notifs[#_G.notifs].msg or "" + p:close() + assert.is_nil(said:find("wrapped", 1, true)) + end) + it("step_review reports the walk over when every candidate is stale", function() vim.cmd("silent! only") local p = Panel.new({ diff --git a/test/nvim/pr_nav_spec.lua b/test/nvim/pr_nav_spec.lua index 4d2a7db..65fb1ae 100644 --- a/test/nvim/pr_nav_spec.lua +++ b/test/nvim/pr_nav_spec.lua @@ -1218,7 +1218,7 @@ describe("pr overview commenting", function() assert.is_true(fire_lhs(buf, "gp")) assert.are.equal( - "differ: nothing here to answer; ga comments on the PR", + "differ: nothing here to reply to or quote; ga comments on the PR", _G.notifs[#_G.notifs].msg ) assert.are.equal(buf, vim.api.nvim_get_current_buf()) -- no compose split opened diff --git a/test/nvim/pr_review_spec.lua b/test/nvim/pr_review_spec.lua index 26b4c14..749482c 100644 --- a/test/nvim/pr_review_spec.lua +++ b/test/nvim/pr_review_spec.lua @@ -94,7 +94,7 @@ describe("review.start", function() review.start(s) assert.are.equal("fresh", s.review_id) assert.are.equal( - "differ: review started - comments are drafts until you submit", + "differ: review started; comments are drafts until you submit", _G.notifs[#_G.notifs].msg ) restore() diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 2d82cfb..8da3c3c 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -1136,78 +1136,6 @@ describe("view edit-in-review", function() v:close() end) - it("drives the unstage hook for a staged source (flow C)", function() - vim.cmd("silent! only") - local dir = vim.fn.tempname() - write(dir, "f.txt", "a\nB\nc\n") - local hook_path, applied - local m = diff.build({ - path = "f.txt", - old_rev = "HEAD", - new_rev = "INDEX", - old_text = "a\nb\nc\n", - new_text = "a\nB\nc\n", - root = dir, - }) - local v = View.new(m, { - layout = "stacked", - context = math.huge, - deep_diff = { enabled = true }, - can_stage = true, - staging = { - initial = "staged", - apply = function() - applied = true - return true - end, - refresh = function() end, - }, - on_edit_unstage = function(path) - hook_path = path - end, - }) - v:open() - v:edit_file() - assert.are.equal("f.txt", hook_path) -- the frontend hook ran with the path - assert.is_nil(applied) -- the hook supersedes the in-place unstage - assert.is_truthy(v.edit_win) -- and the editor opened - v:close() - end) - - it("falls back to an in-place unstage when no hook is wired", function() - vim.cmd("silent! only") - local dir = vim.fn.tempname() - write(dir, "f.txt", "a\nB\nc\n") - local reversed = {} - local m = diff.build({ - path = "f.txt", - old_rev = "HEAD", - new_rev = "INDEX", - old_text = "a\nb\nc\n", - new_text = "a\nB\nc\n", - root = dir, - }) - local v = View.new(m, { - layout = "stacked", - context = math.huge, - deep_diff = { enabled = true }, - can_stage = true, - staging = { - initial = "staged", - apply = function(_, _, _, reverse) - reversed[#reversed + 1] = reverse - return true - end, - refresh = function() end, - }, - }) - v:open() - v:edit_file() - assert.are.same({ true }, reversed) -- the hunk was unstaged (reverse=true) - assert.is_truthy(v.edit_win) - v:close() - end) - it("also edits a staged (index) source", function() vim.cmd("silent! only") local dir = vim.fn.tempname() diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua new file mode 100644 index 0000000..5a98b7d --- /dev/null +++ b/test/unit/model_apply_spec.lua @@ -0,0 +1,165 @@ +local apply = require("differ.model.apply") + +-- a model carrying only what splice() reads: the two texts and the hunks +local function model(old_text, new_text, hunks) + return { + path = "f", + old_rev = "A", + new_rev = "B", + old_text = old_text, + new_text = new_text, + hunks = hunks, + } +end + +local function h(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } +end + +local function set(...) + local out = {} + for _, n in ipairs({ ... }) do + out[n] = true + end + return out +end + +describe("splice", function() + -- 1,2,3,4,5 -> 1,2x,3,4y,5y + local two = model( + "1\n2\n3\n4\n5\n", + "1\n2x\n3\n4y\n5y\n", + { h(2, { "2" }, 2, { "2x" }), h(4, { "4", "5" }, 4, { "4y", "5y" }) } + ) + + it("takes the applied hunk and leaves the other", function() + assert.are.equal("1\n2\n3\n4y\n5y\n", apply.splice(two, set(2))) + end) + + it("returns the old text when nothing is applied", function() + assert.are.equal(two.old_text, apply.splice(two, {})) + end) + + it("returns the new text when everything is applied", function() + assert.are.equal(two.new_text, apply.splice(two, set(1, 2))) + end) + + it("reads the unchanged lines from the new side", function() + assert.are.equal("1\n2\n3\n4y\n5y\n", apply.splice(two, set(2), "new")) + end) + + it("places an insertion after the line it follows", function() + local m = model("a\nb\n", "a\nx\ny\nb\n", { h(1, {}, 2, { "x", "y" }) }) + assert.are.equal(m.new_text, apply.splice(m, set(1))) + end) + + it("inserts at the top of the file", function() + local m = model("a\n", "x\na\n", { h(0, {}, 1, { "x" }) }) + assert.are.equal("x\na\n", apply.splice(m, set(1))) + end) + + it("puts back a deletion from the new side", function() + local m = model("a\nb\nc\n", "a\nc\n", { h(2, { "b" }, 1, {}) }) + assert.are.equal(m.old_text, apply.splice(m, {}, "new")) + end) + + describe("the final newline", function() + it("keeps an unterminated old tail no hunk reaches", function() + local m = model("a\nb\nc", "A\nb\nc\n", { h(1, { "a" }, 1, { "A" }) }) + assert.are.equal("A\nb\nc", apply.splice(m, set(1))) + end) + + it("takes the new side's ending when an applied hunk reaches eof", function() + local m = model("a\nb", "a\nB", { h(2, { "b" }, 2, { "B" }) }) + assert.are.equal("a\nB", apply.splice(m, set(1))) + end) + + it("ends terminated after deleting an unterminated last line", function() + local m = model("a\nb", "a\n", { h(2, { "b" }, 1, {}) }) + assert.are.equal("a\n", apply.splice(m, set(1))) + end) + + it("ends unterminated after deleting a last line when the new side does", function() + local m = model("a\nb\n", "a", { h(2, { "b" }, 1, {}) }) + assert.are.equal("a", apply.splice(m, set(1))) + end) + + it("empties the file when every line goes", function() + local m = model("a\nb\n", "", { h(1, { "a", "b" }, 0, {}) }) + assert.are.equal("", apply.splice(m, set(1))) + end) + end) +end) + +describe("join", function() + -- 1,2,3,4,5 -> 1,2x,3,4y,5y + local two = model( + "1\n2\n3\n4\n5\n", + "1\n2x\n3\n4y\n5y\n", + { h(2, { "2" }, 2, { "2x" }), h(4, { "4", "5" }, 4, { "4y", "5y" }) } + ) + + it("rejoins each side's own blocks to that side's text", function() + assert.are.equal(two.old_text, apply.join(two, { { "2" }, { "4", "5" } })) + assert.are.equal(two.new_text, apply.join(two, { { "2x" }, { "4y", "5y" } })) + end) + + it("places any lines at a hunk between the unchanged ones", function() + assert.are.equal("1\n2\nz\n3\n", apply.join(two, { { "2", "z" }, {} })) + end) + + describe("the final newline", function() + it("ends as HEAD's last line does when no hunk reaches it", function() + local m = apply.ended(model("a\nb\nc", "A\nb\nc", { h(1, { "a" }, 1, { "A" }) })) + assert.are.equal("A\nb\nc", apply.join(m, { m.hunks[1].new_lines })) + end) + + it("ends as the side a block at the end holds", function() + local m = apply.ended(model("a\nb", "a\nB\n", { h(2, { "b" }, 2, { "B" }) })) + assert.are.equal("a\nB\n", apply.join(m, { m.hunks[1].new_lines })) + assert.are.equal("a\nb", apply.join(m, { m.hunks[1].old_lines })) + end) + + it("ends as the inserted block does when it reaches eof", function() + local terminated = apply.ended(model("a\nb\n", "a\nb\nc\n", { h(2, {}, 3, { "c" }) })) + assert.are.equal("a\nb\nc\n", apply.join(terminated, { terminated.hunks[1].new_lines })) + local bare = apply.ended(model("a\nb\n", "a\nb\nc", { h(2, {}, 3, { "c" }) })) + assert.are.equal("a\nb\nc", apply.join(bare, { bare.hunks[1].new_lines })) + end) + + it("empties the file when every line goes", function() + local m = apply.ended(model("a\n", "", { h(1, { "a" }, 1, {}) })) + assert.are.equal("", apply.join(m, { {} })) + end) + + -- "a\nb" and "a\nb\n": the two sides differ only in the ending + it("tells apart two sides that hold the same line", function() + local m = apply.ended(model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) })) + assert.are.equal("a\nb\n", apply.join(m, { m.hunks[1].new_lines })) + assert.are.equal("a\nb", apply.join(m, { m.hunks[1].old_lines })) + end) + end) +end) + +describe("ended", function() + local NO_EOL = require("differ.util.text").NO_EOL + + it("marks the last line of an unterminated side in the hunk that holds it", function() + local m = apply.ended(model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) })) + assert.are.same({ "b" .. NO_EOL }, m.hunks[1].old_lines) + assert.are.same({ "b" }, m.hunks[1].new_lines) + end) + + it("leaves a hunk alone that doesn't reach the end", function() + local m = apply.ended(model("a\nb", "A\nb", { h(1, { "a" }, 1, { "A" }) })) + assert.are.same({ "a" }, m.hunks[1].old_lines) + assert.are.same({ "A" }, m.hunks[1].new_lines) + end) +end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua new file mode 100644 index 0000000..184d69e --- /dev/null +++ b/test/unit/model_marks_spec.lua @@ -0,0 +1,401 @@ +local marks = require("differ.model.marks") + +-- classify only reads the four line-range fields, so the hunks here carry just those +local function h(old_start, old_count, new_start, new_count) + return { + old_start = old_start, + old_count = old_count, + new_start = new_start, + new_count = new_count, + } +end + +-- each union hunk's rolled-up state +local function states(m, union) + local out = {} + for i, u in ipairs(union) do + out[i] = marks.state(m, u) + end + return out +end + +describe("union marks", function() + it("calls every hunk unstaged when the index holds nothing", function() + local union = { h(2, 1, 2, 1), h(9, 1, 9, 1) } + local m = marks.classify(union, {}, { h(2, 1, 2, 1), h(9, 1, 9, 1) }) + assert.are.same({ "unstaged", "unstaged" }, states(m, union)) + assert.is_false(m.old[2]) + assert.is_false(m.new[2]) + end) + + it("calls every hunk staged when the worktree matches the index", function() + local union = { h(2, 1, 2, 1) } + local m = marks.classify(union, { h(2, 1, 2, 1) }, {}) + assert.are.same({ "staged" }, states(m, union)) + assert.is_true(m.old[2]) + assert.is_true(m.new[2]) + end) + + -- one file, one hunk staged and another not: each rolls up on its own, so a file + -- half in the index still reads hunk by hunk rather than as one blurred state + it("rolls each hunk up separately", function() + local union = { h(1, 1, 1, 1), h(10, 1, 10, 1) } + local m = marks.classify(union, { h(1, 1, 1, 1) }, { h(10, 1, 10, 1) }) + assert.are.same({ "staged", "unstaged" }, states(m, union)) + end) + + -- the ticket's repro: a hunk staged, then two more edits inside the region it + -- covers, so HEAD↔worktree collapses all three into one hunk. that hunk is partial, + -- and the per-line marks still say which line of it the index already has + it("marks a hunk partial when later edits land inside a staged one", function() + local union = { h(3, 3, 3, 3) } + local m = marks.classify(union, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) }) + assert.are.same({ "partial" }, states(m, union)) + assert.are.same({ [3] = true, [4] = false, [5] = false }, m.old) + assert.are.same({ [3] = true, [4] = false, [5] = false }, m.new) + end) + + -- a hunk carrying content, for the checks that compare lines rather than ranges + local function hl(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } + end + + describe("completeness", function() + it("passes a file whose half-staged content is all on screen", function() + assert.is_true(marks.complete({ h(3, 3, 3, 3) }, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) })) + end) + + -- stage an edit, then put the worktree back: git still calls the file MM, both + -- pairs hold a change, and HEAD↔worktree is empty + it("catches an edit staged and then undone in the worktree", function() + assert.is_false(marks.complete({}, { h(2, 1, 2, 1) }, { h(2, 1, 2, 1) })) + end) + + it("catches unstaged content no hunk covers", function() + assert.is_false(marks.complete({ h(3, 1, 3, 1) }, {}, { h(9, 1, 9, 1) })) + end) + + it("catches staged content no hunk covers", function() + assert.is_false(marks.complete({ h(3, 1, 3, 1) }, { h(9, 1, 9, 1) }, {})) + end) + + -- HEAD x,y; index x,ghost,y; worktree X,y. the line the index added and the + -- worktree then dropped reaches neither side of a HEAD↔worktree diff + it("catches a line only the index holds", function() + local union = { hl(1, { "x" }, 1, { "X" }) } + local cached = { hl(1, {}, 2, { "ghost" }) } + local unstaged = { hl(1, { "x", "ghost" }, 1, { "X" }) } + assert.is_false(marks.complete(union, cached, unstaged)) + end) + + -- the pair diffs can align repeated lines differently from the union: here the + -- worktree drops a HEAD line the union still shows as context + it("catches a replaced line the union shows as context", function() + local union = { h(1, 1, 1, 1) } + local unstaged = { h(5, 1, 4, 0) } + assert.is_false(marks.complete(union, {}, unstaged)) + end) + + -- stage an edit, then edit the same line again: the index's version is replaced + -- in the worktree, so it reaches neither side of a HEAD↔worktree diff + it("catches a staged line edited again in the worktree", function() + local union = { hl(1, { "foo" }, 1, { "FOOD" }) } + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + assert.is_false(marks.complete(union, cached, unstaged)) + end) + + -- HEAD a,a,a,b; index a,a,c,b; worktree b,a,c,b. every range check passes, but + -- the marks keep two HEAD a's and hold the worktree's a too: five index lines + -- where there are four + it("catches marks that count a line twice", function() + local union = { hl(1, { "a", "a", "a" }, 1, { "b", "a", "c" }) } + local cached = { hl(3, { "a" }, 3, { "c" }) } + local unstaged = { hl(1, { "a" }, 1, { "b" }) } + assert.is_false(marks.complete(union, cached, unstaged)) + end) + + it("allows a deletion of a line HEAD held too", function() + local union = { hl(7, { "gone" }, 7, {}) } + local unstaged = { hl(7, { "gone" }, 7, {}) } + assert.is_true(marks.complete(union, {}, unstaged)) + end) + end) + + describe("locating hidden content", function() + local head = { "1", "2", "3", "4", "5" } + + it("names the hunk whose staged line was edited again", function() + local union = { hl(1, { "foo" }, 1, { "FOOD" }) } + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + local m = marks.classify(union, cached, unstaged) + assert.are.same({ 1 }, marks.hidden_in({ "foo" }, union, cached, m)) + end) + + -- HEAD 1..5, line 3 staged as 3x, then 4 edited on top in the worktree + it("names nothing when the view holds what the index does", function() + local union = { hl(3, { "3", "4" }, 3, { "3x", "4y" }) } + local cached = { hl(3, { "3" }, 3, { "3x" }) } + local unstaged = { hl(4, { "4" }, 4, { "4y" }) } + local m = marks.classify(union, cached, unstaged) + assert.are.same({}, marks.hidden_in(head, union, cached, m)) + end) + end) + + describe("local hunks over staged content", function() + it("names a local hunk rewriting a line the index added", function() + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + assert.are.same({ 1 }, marks.restaged(unstaged, cached)) + end) + + it("names a local hunk putting back a line the index deleted", function() + local cached = { hl(3, { "3" }, 2, {}) } + local unstaged = { hl(2, {}, 3, { "3" }) } + assert.are.same({ 1 }, marks.restaged(unstaged, cached)) + end) + + it("leaves out local hunks on lines the index left alone", function() + local cached = { hl(3, { "3" }, 3, { "3x" }) } + local beside = hl(3, {}, 4, { "new" }) -- an insertion after the staged line + local apart = hl(5, { "5" }, 6, { "5y" }) + assert.are.same({}, marks.restaged({ beside, apart }, cached)) + end) + end) + + describe("meets", function() + it("meets on shared lines and not on lines apart", function() + assert.is_true(marks.meets(h(3, 2, 3, 2), "old", h(4, 1, 4, 1), "old")) + assert.is_false(marks.meets(h(3, 1, 3, 1), "old", h(4, 1, 4, 1), "old")) + end) + + it("meets an insertion at either edge of its lines", function() + local lines = h(3, 1, 3, 2) -- HEAD line 3 + assert.is_true(marks.meets(h(2, 0, 3, 1), "old", lines, "old")) -- after line 2 + assert.is_true(marks.meets(lines, "old", h(3, 0, 4, 1), "old")) -- after line 3 + assert.is_false(marks.meets(h(1, 0, 2, 1), "old", lines, "old")) + assert.is_false(marks.meets(h(4, 0, 5, 1), "old", lines, "old")) + end) + + it("meets two insertions only at the same place", function() + assert.is_true(marks.meets(h(2, 0, 3, 1), "old", h(2, 0, 3, 2), "old")) + assert.is_false(marks.meets(h(2, 0, 3, 1), "old", h(3, 0, 4, 1), "old")) + end) + + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: one pair hunk each + -- way spans both union hunks, through the B C only the index holds + it("names the other union hunk a pair hunk reaches", function() + local union = { h(2, 1, 2, 1), h(5, 2, 5, 2) } + local pair = { h(2, 5, 2, 5) } + assert.are.equal(2, marks.shared_with(union, union[1], pair, "new")) + assert.are.equal(1, marks.shared_with(union, union[2], pair, "old")) + end) + + it("names no hunk when each pair hunk stays in one", function() + local union = { h(2, 1, 2, 1), h(5, 2, 5, 2) } + local pair = { h(2, 1, 2, 1), h(5, 1, 5, 1) } + assert.is_nil(marks.shared_with(union, union[1], pair, "new")) + assert.is_nil(marks.shared_with(union, union[2], pair, "new")) + end) + + it("compares the sides it's given", function() + -- index↔worktree deletes index line 3; HEAD↔index changes it + assert.is_true(marks.meets(h(3, 1, 2, 0), "old", h(3, 1, 3, 1), "new")) + end) + end) + + describe("blocks", function() + -- HEAD a b c d, worktree a B c D e: b and d rewritten, e added at the end + local head = { "a", "b", "c", "d" } + local union = { hl(2, { "b" }, 2, { "B" }), hl(4, { "d" }, 4, { "D", "e" }) } + + it("reads HEAD's lines at every hunk of an index that is HEAD", function() + assert.are.same({ { "b" }, { "d" } }, marks.blocks(union, head, head)) + end) + + it("reads whatever the index holds at a hunk", function() + local index = { "a", "B", "c", "D" } + assert.are.same({ { "B" }, { "D" } }, marks.blocks(union, head, index)) + index = { "a", "b", "X", "Y", "c", "d" } + assert.are.same({ { "b", "X", "Y" }, { "d" } }, marks.blocks(union, head, index)) + end) + + it("gives nil when the index changes a line between hunks", function() + assert.is_nil(marks.blocks(union, head, { "a", "b", "C", "d" })) + end) + + -- HEAD a x c, worktree a x x c: the inserted x also fits HEAD's own x + it("settles a block its unchanged lines repeat by the lines after it", function() + local repeat_head = { "a", "x", "c" } + local insert = { hl(1, {}, 2, { "x" }) } + assert.are.same({ {} }, marks.blocks(insert, repeat_head, repeat_head)) + local index = { "a", "x", "x", "c" } + assert.are.same({ { "x" } }, marks.blocks(insert, repeat_head, index)) + end) + + -- HEAD a b _ _ c d, worktree a b B _ d: a blank either side of hunk 1's insertion, + -- so the index's first blank fits hunk 1's block or the context after it + it("reads a block by the hunk's own lines where a repeated line fits two ways", function() + local blank = { "a", "b", "", "", "c", "d" } + local around = { hl(2, {}, 3, { "B" }), hl(4, { "", "c" }, 4, {}) } + assert.are.same({ {}, { "", "c" } }, marks.blocks(around, blank, blank)) + local index = { "a", "b", "B", "", "", "c", "d" } + assert.are.same({ { "B" }, { "", "c" } }, marks.blocks(around, blank, index)) + index = { "a", "b", "", "d" } + assert.are.same({ {}, {} }, marks.blocks(around, blank, index)) + end) + + it("gives the last hunk the rest of the index", function() + local index = { "a", "b", "c", "D", "e", "f" } + assert.are.same({ { "b" }, { "D", "e", "f" } }, marks.blocks(union, head, index)) + end) + + -- a blank file with a line rewritten as two every 25 lines, and the index holding + -- one of the two: nearly every blank is a place the block could end + it("gives nil rather than search a mostly blank file for long", function() + local blank_head, blank_index, spread = {}, {}, {} + for l = 1, 2000 do + local line = "" + if l % 25 == 0 then + line = "old" .. l + local n = #spread + spread[n + 1] = hl(l, { line }, l + n, { "x" .. l, "y" .. l }) + blank_index[l] = "x" .. l + end + blank_head[l], blank_index[l] = line, blank_index[l] or line + end + assert.is_nil(marks.blocks(spread, blank_head, blank_index)) + end) + end) + + describe("of_blocks", function() + -- HEAD 1 a b 4, worktree 1 A B C 4: one hunk rewriting two lines as three + local union = { hl(2, { "a", "b" }, 2, { "A", "B", "C" }) } + + it("reads a block of HEAD's lines as unstaged and the worktree's as staged", function() + local m = marks.of_blocks(union, { { "a", "b" } }) + assert.are.same({ "unstaged" }, states(m, union)) + m = marks.of_blocks(union, { { "A", "B", "C" } }) + assert.are.same({ "staged" }, states(m, union)) + end) + + it("marks each line of a block that holds part of the hunk", function() + local m, hidden = marks.of_blocks(union, { { "a", "B" } }) + assert.are.same({ "partial" }, states(m, union)) + assert.are.same({ [2] = false, [3] = true }, m.old) + assert.are.same({ [2] = false, [3] = true, [4] = false }, m.new) + assert.are.same({}, hidden) + end) + + it("names the hunk whose block holds a line neither side has", function() + local _, hidden = marks.of_blocks(union, { { "a", "Z" } }) + assert.are.same({ 1 }, hidden) + end) + + -- HEAD a a, worktree a z: the two sides share an a + it("reads a block that is one whole side as that side", function() + local shared = { hl(1, { "a", "a" }, 1, { "a", "z" }) } + assert.are.same({ "staged" }, states(marks.of_blocks(shared, { { "a", "z" } }), shared)) + assert.are.same( + { "unstaged" }, + states(marks.of_blocks(shared, { { "a", "a" } }), shared) + ) + end) + + -- HEAD x a, worktree a x: a block of HEAD's lines pairs with the old side first + it("reads a moved line from HEAD before the worktree", function() + local moved = { hl(1, { "x", "a" }, 1, { "a", "x" }) } + local m, hidden = marks.of_blocks(moved, { { "x", "a" } }) + assert.are.same({ "unstaged" }, states(m, moved)) + assert.are.same({}, hidden) + end) + + -- HEAD x a, worktree a y: a block of a alone is x dropped from HEAD, or the + -- worktree's a with y still to come. both describe the same index + it("reads a partial block's shared line as HEAD's", function() + local shared = { hl(1, { "x", "a" }, 1, { "a", "y" }) } + local m, hidden = marks.of_blocks(shared, { { "a" } }) + assert.are.same({ "partial" }, states(m, shared)) + assert.are.same({ [1] = true, [2] = false }, m.old) + assert.are.same({ [1] = false, [2] = false }, m.new) + assert.are.same({}, hidden) + end) + end) + + -- past MAX_CELLS the block isn't compared line by line, and an uncompared hunk takes + -- the whole file to the pair diffs rather than marking half of it + it("gives no marks for a hunk too big to compare", function() + local old_lines, block = {}, {} + for i = 1, 1001 do + old_lines[i] = "line " .. i + block[i] = "line " .. i + end + block[1] = "changed" -- so the block isn't one whole side, which needs no compare + local union = { hl(1, old_lines, 1, { "one" }) } + local m, hidden = marks.of_blocks(union, { block }) + assert.is_nil(m) + assert.are.same({}, hidden) + end) + + describe("a last line without its newline", function() + local text = require("differ.util.text") + local NO = text.NO_EOL + local S, U, P = "staged", "unstaged", "partial" + + -- HEAD "a\nb", worktree "a\nb\n": one hunk, b on both sides + it("tells a line from the same line with its newline", function() + local union = { hl(2, { "b" .. NO }, 2, { "b" }) } + local head = text.ended_lines("a\nb") + local unstaged = marks.blocks(union, head, text.ended_lines("a\nb")) + local staged = marks.blocks(union, head, text.ended_lines("a\nb\n")) + assert.are.same({ U }, states(marks.of_blocks(union, unstaged), union)) + assert.are.same({ S }, states(marks.of_blocks(union, staged), union)) + end) + + -- HEAD "a\nb", worktree "a\nB\n": the index holds B without the newline + it("reads a last line the index holds without its newline as neither side's", function() + local union = { hl(2, { "b" .. NO }, 2, { "B" }) } + local blocks = marks.blocks(union, text.ended_lines("a\nb"), text.ended_lines("a\nB")) + local m, hidden = marks.of_blocks(union, blocks) + assert.are.same({ P }, states(m, union)) + assert.are.same({ 1 }, hidden) + end) + + -- HEAD "a\nx\nc\nb\na\na\n", worktree "a\nc\nay\nz4\na\na": the index's last `a` + -- has no newline, so it's the worktree's added line, not HEAD's last one + it("cuts an unterminated last line into the hunk that ends the worktree", function() + local union = { + hl(2, { "x" }, 1, {}), + hl(4, { "b", "a" }, 3, { "ay", "z4" }), + hl(6, {}, 6, { "a" .. NO }), + } + local head = text.ended_lines("a\nx\nc\nb\na\na\n") + local blocks = marks.blocks(union, head, text.ended_lines("a\nx\nc\nb\na\na")) + assert.are.same({ { "x" }, { "b" }, { "a" .. NO } }, blocks) + assert.are.same({ U, P, S }, states(marks.of_blocks(union, blocks), union)) + end) + end) + + describe("shift", function() + -- three lines inserted at the top, and line 5 replaced by two + local hunks = { h(0, 0, 1, 3), h(5, 1, 8, 2) } + + it("moves a line by the hunks that end before it", function() + assert.are.equal(3, marks.shift(hunks, 1, "old")) + assert.are.equal(4, marks.shift(hunks, 6, "old")) + assert.are.equal(-3, marks.shift(hunks, 4, "new")) + end) + + it("leaves out a hunk that starts on the line", function() + assert.are.equal(3, marks.shift(hunks, 5, "old")) + end) + end) +end) diff --git a/test/unit/model_stage_spec.lua b/test/unit/model_stage_spec.lua new file mode 100644 index 0000000..5efc684 --- /dev/null +++ b/test/unit/model_stage_spec.lua @@ -0,0 +1,141 @@ +local stage = require("differ.model.stage") + +local function model(old_text, new_text, hunks) + return { + path = "f", + old_rev = "A", + new_rev = "B", + old_text = old_text, + new_text = new_text, + hunks = hunks, + } +end + +local function h(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } +end + +-- HEAD 1..5, worktree 1 2x 3 4 5y: one hunk on line 2 and one on the last line +local HEAD = "1\n2\n3\n4\n5\n" +local WORK = "1\n2x\n3\n4\n5y\n" +local function pairs_of(index) + local hunks = { h(2, { "2" }, 2, { "2x" }), h(5, { "5" }, 5, { "5y" }) } + return model(HEAD, WORK, hunks), model(HEAD, index, {}), model(index, WORK, hunks) +end + +describe("index_blocks", function() + it("reads each hunk's lines from an index that is HEAD", function() + local union = (pairs_of(HEAD)) + local blocks = stage.index_blocks(union, HEAD) + assert.are.same({ { "2" }, { "5" } }, blocks) + end) + + it("gives nil when the index changes a line between the hunks", function() + local union = (pairs_of(HEAD)) + assert.is_nil(stage.index_blocks(union, "1\n2\n3x\n4\n5\n")) + end) +end) + +describe("next_index", function() + it("stages a hunk by giving the index the worktree's lines", function() + local union, cached, unstaged = pairs_of(HEAD) + local text = stage.next_index(union, cached, unstaged, union.hunks[1], true) + assert.are.equal("1\n2x\n3\n4\n5\n", text) + end) + + it("unstages a hunk by giving the index HEAD's lines", function() + local index = "1\n2x\n3\n4\n5\n" + local union, cached, unstaged = pairs_of(index) + cached.hunks = { h(2, { "2" }, 2, { "2x" }) } + unstaged.hunks = { h(5, { "5" }, 5, { "5y" }) } + local text = stage.next_index(union, cached, unstaged, union.hunks[1], false) + assert.are.equal(HEAD, text) + end) + + -- HEAD "a\nb", worktree "a\nb\n": the hunk is the ending alone + it("takes the ending with the hunk that reaches the end of the file", function() + local hunks = { h(2, { "b" }, 2, { "b" }) } + local union = model("a\nb", "a\nb\n", hunks) + local cached = model("a\nb", "a\nb", {}) + local unstaged = model("a\nb", "a\nb\n", hunks) + assert.are.equal("a\nb\n", stage.next_index(union, cached, unstaged, union.hunks[1], true)) + end) + + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: the index's B and C + -- sit between the two union hunks, so one HEAD↔index hunk covers both + it("names the other hunk a pair hunk would carry the op into", function() + local head = "1\n2\n3\n4\n5\n6\n7\n" + local index = "1\nA\nB\nC\nD\nE\n7\n" + local work = "1\nA2\n3\n4\nD2\nE2\n7\n" + local union = model(head, work, { + h(2, { "2" }, 2, { "A2" }), + h(5, { "5", "6" }, 5, { "D2", "E2" }), + }) + local cached = model( + head, + index, + { h(2, { "2", "3", "4", "5", "6" }, 2, { "A", "B", "C", "D", "E" }) } + ) + local unstaged = model( + index, + work, + { h(2, { "A", "B", "C", "D", "E" }, 2, { "A2", "3", "4", "D2", "E2" }) } + ) + local text, reaches = stage.next_index(union, cached, unstaged, union.hunks[1], false) + assert.is_nil(text) + assert.are.equal(2, reaches) + end) + + -- HEAD 1 2 x x 5, index 1 x x 5x, worktree 1 2x x 5x: the index drops a line the + -- union shows under hunk 1, so the pair hunks leave hunk 2 nothing of its own. the + -- op has to say so rather than hand back the index's own text + it("gives no text and no reaches for a hunk neither pair sits at", function() + local head = "1\n2\nx\nx\n5\n" + local index = "1\nx\nx\n5x\n" + local work = "1\n2x\nx\n5x\n" + local union = model(head, work, { + h(2, { "2" }, 2, { "2x" }), + h(4, { "x", "5" }, 4, { "5x" }), + }) + local cached = model(head, index, { h(2, { "2" }, 2, {}), h(5, { "5" }, 4, { "5x" }) }) + local unstaged = model(index, work, { h(2, {}, 2, { "2x" }) }) + local text, reaches = stage.next_index(union, cached, unstaged, union.hunks[2], true) + assert.is_nil(text) + assert.is_nil(reaches) + end) + + it("never hands back the text the index already holds", function() + local union, cached, unstaged = pairs_of(HEAD) + -- both hunks are unstaged, so unstaging one has nothing to write + assert.is_nil(stage.next_index(union, cached, unstaged, union.hunks[1], false)) + end) +end) + +describe("model.stage.drop_lines", function() + it("takes back only the lines asked for inside a block of the same length", function() + local cached = model("1\n2\n3\n4\n5\n", "1\nB\nC\nD\n5\n", { + h(2, { "2", "3", "4" }, 2, { "B", "C", "D" }), + }) + assert.are.equal("1\nB\n3\nD\n5\n", stage.drop_lines(cached, 1, 3, 3)) + end) + + it("keeps every other staged hunk", function() + local cached = model("1\n2\n3\n4\n5\n", "X\n2\nC\nD\n5\n", { + h(1, { "1" }, 1, { "X" }), + h(3, { "3", "4" }, 3, { "C", "D" }), + }) + assert.are.equal("X\n2\n3\nD\n5\n", stage.drop_lines(cached, 2, 3, 3)) + end) + + it("takes back a block's last line and keeps the file's ending", function() + local cached = model("1\n2", "1\nB", { h(2, { "2" }, 2, { "B" }) }) + assert.are.equal("1\n2", stage.drop_lines(cached, 1, 2, 2)) + end) +end) diff --git a/test/unit/render_spec.lua b/test/unit/render_spec.lua new file mode 100644 index 0000000..1a69368 --- /dev/null +++ b/test/unit/render_spec.lua @@ -0,0 +1,88 @@ +local render = require("differ.render") + +-- HEAD "a\nb", worktree "a\nb\n": one hunk, b on both sides +local model = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "a\nb\n", + hunks = { + { + old_start = 2, + old_count = 1, + new_start = 2, + new_count = 1, + old_lines = { "b" }, + new_lines = { "b" }, + }, + }, +} + +-- each flagged row as "kind:lnum" +local function flagged(column) + local out = {} + for _, line in ipairs(column.map.lines) do + if line.no_eol then + out[#out + 1] = line.kind .. ":" .. (line[line.kind] or "") + end + end + return out +end + +describe("render no_eol", function() + it("flags the old side's last line in stacked", function() + local r = render.render(model, { layout = "stacked", context = math.huge }) + assert.are.same({ "old:2" }, flagged(r.columns[1])) + end) + + it("flags the old column's last line in split", function() + local r = render.render(model, { layout = "split", context = math.huge }) + assert.are.same({ "old:2" }, flagged(r.columns[1])) + assert.are.same({}, flagged(r.columns[2])) + end) + + it("leaves an unterminated line alone when no hunk shows it", function() + local context = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "A\nb", + hunks = { + { + old_start = 1, + old_count = 1, + new_start = 1, + new_count = 1, + old_lines = { "a" }, + new_lines = { "A" }, + }, + }, + } + local r = render.render(context, { layout = "stacked", context = math.huge }) + assert.are.same({}, flagged(r.columns[1])) + end) + + it("leaves both last lines alone when neither side ends in a newline", function() + local both = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "a\nB", + hunks = { + { + old_start = 2, + old_count = 1, + new_start = 2, + new_count = 1, + old_lines = { "b" }, + new_lines = { "B" }, + }, + }, + } + local r = render.render(both, { layout = "stacked", context = math.huge }) + assert.are.same({}, flagged(r.columns[1])) + end) +end) diff --git a/test/unit/winbar_spec.lua b/test/unit/winbar_spec.lua index 4d44817..425615c 100644 --- a/test/unit/winbar_spec.lua +++ b/test/unit/winbar_spec.lua @@ -62,6 +62,37 @@ describe("winbar.hunk_at", function() end) end) +describe("winbar.tally", function() + -- a view is only read through hunk_tally here + local function view(states) + return { + hunk_tally = function() + local counts = { staged = 0, partial = 0, total = #states } + for _, state in ipairs(states) do + if state ~= "unstaged" then + counts[state] = counts[state] + 1 + end + end + return counts + end, + } + end + + it("counts staged and partial hunks, leaving zero counts out", function() + assert.are.equal("1 staged · 1 partial · ", winbar.tally(view({ "staged", "partial" }))) + assert.are.equal("1 partial · ", winbar.tally(view({ "partial", "unstaged" }))) + assert.are.equal("", winbar.tally(view({ "unstaged" }))) + end) + + it("is empty once every hunk is staged", function() + assert.are.equal("", winbar.tally(view({ "staged", "staged" }))) + end) + + it("is empty off a hunk-staging source", function() + assert.are.equal("", winbar.tally({ hunk_tally = function() end })) + end) +end) + describe("winbar.hunk_at with no hunks", function() it("is always 0", function() local map = stacked.render({