From 60d84b5a317085f81a25567202b8cdba5dc9e072 Mon Sep 17 00:00:00 2001 From: Max Fang Date: Mon, 3 Nov 2025 17:20:53 -0300 Subject: [PATCH 1/3] multi: Add buffer_hunk_reset functionality --- .../features/screens/DiffScreen/Model.lua | 5 +++ lua/vgit/features/screens/DiffScreen/init.lua | 43 +++++++++++++++++++ lua/vgit/git/GitFile.lua | 4 ++ lua/vgit/git/git_stager.lua | 28 ++++++++++++ lua/vgit/settings/diff_preview.lua | 6 ++- 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/lua/vgit/features/screens/DiffScreen/Model.lua b/lua/vgit/features/screens/DiffScreen/Model.lua index 247ee6b6..c029f282 100644 --- a/lua/vgit/features/screens/DiffScreen/Model.lua +++ b/lua/vgit/features/screens/DiffScreen/Model.lua @@ -106,6 +106,11 @@ function Model:unstage_hunk(filename, hunk) return git_file:unstage_hunk(hunk) end +function Model:reset_hunk(filename, hunk) + local git_file = GitFile(filename) + return git_file:reset_hunk(hunk) +end + function Model:stage_file(filename) local reponame = git_repo.discover() return git_stager.stage(reponame, filename) diff --git a/lua/vgit/features/screens/DiffScreen/init.lua b/lua/vgit/features/screens/DiffScreen/init.lua index 8c1c3495..9cb78cb1 100644 --- a/lua/vgit/features/screens/DiffScreen/init.lua +++ b/lua/vgit/features/screens/DiffScreen/init.lua @@ -59,6 +59,7 @@ function DiffScreen:create_app_bar_view(scene, model) return { { 'Stage', keymaps['buffer_stage'] }, { 'Stage hunk', keymaps['buffer_hunk_stage'] }, + { 'Reset hunk', keymaps['buffer_hunk_reset'] }, { 'Reset', keymaps['reset'] }, { 'Switch to Unstage View', keymaps['toggle_view'] }, } @@ -227,6 +228,41 @@ function DiffScreen:unstage_hunk(buffer) self.diff_view:move_to_hunk(index, 'center') end +function DiffScreen:reset_hunk(buffer) + if self.model:is_hunk() then return end + if self.model:is_staged() then return end + + loop.free_textlock() + local filename = self.model:get_filename() + if not filename then return end + + loop.free_textlock() + local hunk, index = self.diff_view:get_hunk_under_cursor() + if not hunk then return end + + loop.free_textlock() + local decision = console.input( + 'Are you sure you want to discard this hunk? (y/N) ' + ):lower() + + if decision ~= 'yes' and decision ~= 'y' then return end + + loop.free_textlock() + self.model:reset_hunk(filename, hunk) + + loop.free_textlock() + local _, refetch_err = self.model:fetch(buffer:get_name()) + loop.free_textlock() + + if refetch_err then + console.debug.error(refetch_err).error(refetch_err) + return + end + + self.diff_view:render() + self.diff_view:move_to_hunk(index, 'center') +end + function DiffScreen:stage(buffer) if self.model:is_hunk() then return end if self.model:is_staged() then return end @@ -327,6 +363,13 @@ function DiffScreen:setup_keymaps(buffer) mapping = keymaps.buffer_hunk_unstage, handler = handlers.hunk_unstage, }, + { + mode = 'n', + mapping = keymaps.buffer_hunk_reset, + handler = loop.debounce_coroutine(function() + self:reset_hunk(buffer) + end, 100), + }, { mode = 'n', mapping = { diff --git a/lua/vgit/git/GitFile.lua b/lua/vgit/git/GitFile.lua index 0e4673f6..23fd174d 100644 --- a/lua/vgit/git/GitFile.lua +++ b/lua/vgit/git/GitFile.lua @@ -57,6 +57,10 @@ function GitFile:unstage_hunk(hunk) return git_stager.unstage_hunk(self.reponame, self.filename, hunk) end +function GitFile:reset_hunk(hunk) + return git_stager.reset_hunk(self.reponame, self.filename, hunk) +end + function GitFile:stage() return git_stager.stage(self.reponame, self.filename) end diff --git a/lua/vgit/git/git_stager.lua b/lua/vgit/git/git_stager.lua index 34dc420c..257b3e94 100644 --- a/lua/vgit/git/git_stager.lua +++ b/lua/vgit/git/git_stager.lua @@ -84,4 +84,32 @@ function git_stager.unstage_hunk(reponame, filename, hunk) return nil, err end +-- Reset (discard) a hunk in the working directory +function git_stager.reset_hunk(reponame, filename, hunk) + if not reponame then return nil, { 'reponame is required' } end + if not filename then return nil, { 'filename is required' } end + if not hunk then return nil, { 'hunk is required' } end + + local patch = GitPatch(filename, hunk) + local patch_filename = fs.tmpname() + + fs.write_file(patch_filename, patch) + + -- Apply the patch in reverse to the working directory (not staged) + local _, err = gitcli.run({ + '-C', + reponame, + '--no-pager', + 'apply', + '--reverse', + '--whitespace=nowarn', + '--unidiff-zero', + patch_filename, + }) + + fs.remove_file(patch_filename) + + return nil, err +end + return git_stager diff --git a/lua/vgit/settings/diff_preview.lua b/lua/vgit/settings/diff_preview.lua index 2dd0da2a..7f830e4e 100644 --- a/lua/vgit/settings/diff_preview.lua +++ b/lua/vgit/settings/diff_preview.lua @@ -11,7 +11,7 @@ return Config({ desc = 'Unstage' }, reset = { - key = 'r', + key = 'R', desc = 'Reset' }, buffer_hunk_stage = { @@ -22,6 +22,10 @@ return Config({ key = 'u', desc = 'Unstage hunk' }, + buffer_hunk_reset = { + key = 'r', + desc = 'Reset hunk' + }, toggle_view = 't', }, }) From 78a4ffc3baf9e20f34a871695c4d2bd6ce2044a1 Mon Sep 17 00:00:00 2001 From: Max Fang Date: Tue, 28 Oct 2025 18:54:19 -0700 Subject: [PATCH 2/3] fix(git_buffer_store): Prevent autocmd accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VGitSync autocmd handler was being registered inside the filesystem watcher callback, causing a new handler to accumulate on every git directory change (i.e., every stage operation). After N stage operations, N duplicate handlers would all fire on each subsequent stage, causing O(N²) performance degradation. Fixed by moving event.custom_on() registration outside the callback, using vim.schedule() to avoid luv callback restrictions. --- lua/vgit/git/git_buffer_store.lua | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lua/vgit/git/git_buffer_store.lua b/lua/vgit/git/git_buffer_store.lua index 5db6a455..9af4c169 100644 --- a/lua/vgit/git/git_buffer_store.lua +++ b/lua/vgit/git/git_buffer_store.lua @@ -35,6 +35,17 @@ git_buffer_store.register_events = loop.coroutine(function() loop.free_textlock() local git_dirname = git_repo.dirname() + + -- Register VGitSync handler once, outside the filesystem callback + -- Use vim.schedule to avoid "must not be called in a lua loop callback" error + vim.schedule(function() + event.custom_on('VGitSync', function() + git_buffer_store.for_each(function(buffer) + git_buffer_store.dispatch(buffer, 'sync') + end) + end) + end) + local ok = handle:start( git_dirname, {}, @@ -44,16 +55,12 @@ git_buffer_store.register_events = loop.coroutine(function() loop.free_textlock() local git_dir = git_repo.dirname() loop.free_textlock() + -- Emit event to trigger the handler registered above event.emit('VGitSync', { git_dir = git_dir, filename = filename, event_name = event_name, }) - event.custom_on('VGitSync', function() - git_buffer_store.for_each(function(buffer) - git_buffer_store.dispatch(buffer, 'sync') - end) - end) end end, 10) ) From 8cf121455976321471748f8355ecb79cc8fc5363 Mon Sep 17 00:00:00 2001 From: Max Fang Date: Wed, 5 Nov 2025 12:33:43 -0300 Subject: [PATCH 3/3] perf: suppress VGitSync during staging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds API to git_buffer_store for suppressing VGitSync events during staging. Applies suppression in DiffScreen staging methods to prevent cascading buffer refreshes. Performance from local profiling: 95% reduction in calls (10,421 → 564 per session staging 39 hunks), 99.6% reduction in LiveGutter:fetch (1,380 → 6). DiffScreen already manually fetches and renders, making VGitSync refresh of all tracked buffers redundant. --- lua/vgit/features/screens/DiffScreen/init.lua | 25 ++++++++ lua/vgit/git/git_buffer_store.lua | 61 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/lua/vgit/features/screens/DiffScreen/init.lua b/lua/vgit/features/screens/DiffScreen/init.lua index 9cb78cb1..881d3344 100644 --- a/lua/vgit/features/screens/DiffScreen/init.lua +++ b/lua/vgit/features/screens/DiffScreen/init.lua @@ -138,6 +138,10 @@ function DiffScreen:reset(buffer) local filename = self.model:get_filename() if not filename then return end + -- Performance: Suppress VGitSync during reset since we manually fetch below + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + loop.free_textlock() self.model:reset_file(filename) @@ -185,6 +189,11 @@ function DiffScreen:stage_hunk(buffer) local hunk, index = self.diff_view:get_hunk_under_cursor() if not hunk then return end + -- Performance: Suppress VGitSync during staging since we manually fetch below + -- This prevents refreshing all tracked buffers unnecessarily + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + self.model:stage_hunk(filename, hunk) loop.free_textlock() @@ -212,6 +221,10 @@ function DiffScreen:unstage_hunk(buffer) local hunk, index = self.diff_view:get_hunk_under_cursor() if not hunk then return end + -- Performance: Suppress VGitSync during unstaging since we manually fetch below + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + loop.free_textlock() self.model:unstage_hunk(filename, hunk) @@ -247,6 +260,10 @@ function DiffScreen:reset_hunk(buffer) if decision ~= 'yes' and decision ~= 'y' then return end + -- Performance: Suppress VGitSync during reset since we manually fetch below + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + loop.free_textlock() self.model:reset_hunk(filename, hunk) @@ -271,6 +288,10 @@ function DiffScreen:stage(buffer) local filename = self.model:get_filename() if not filename then return end + -- Performance: Suppress VGitSync during staging since we manually fetch below + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + loop.free_textlock() self.model:stage_file(filename) @@ -292,6 +313,10 @@ function DiffScreen:unstage(buffer) local filename = self.model:get_filename() if not filename then return end + -- Performance: Suppress VGitSync during unstaging since we manually fetch below + local git_buffer_store = require('vgit.git.git_buffer_store') + git_buffer_store.suppress_sync_for(200) + loop.free_textlock() self.model:unstage_file(filename) diff --git a/lua/vgit/git/git_buffer_store.lua b/lua/vgit/git/git_buffer_store.lua index 9af4c169..e0c29e13 100644 --- a/lua/vgit/git/git_buffer_store.lua +++ b/lua/vgit/git/git_buffer_store.lua @@ -16,6 +16,13 @@ local events = { } local is_registered = false +-- Performance optimization: control VGitSync refresh behavior during staging +local staging_state = { + active = false, -- True when selective refresh mode is active + target_buffer = nil, -- The specific buffer to refresh (nil = all buffers) + suppress_sync = false, -- True to completely suppress VGitSync events +} + local git_buffer_store = {} git_buffer_store.register_events = loop.coroutine(function() @@ -40,6 +47,21 @@ git_buffer_store.register_events = loop.coroutine(function() -- Use vim.schedule to avoid "must not be called in a lua loop callback" error vim.schedule(function() event.custom_on('VGitSync', function() + -- Skip sync entirely if suppressed (e.g., during batch staging operations) + if staging_state.suppress_sync then + return + end + + -- Selective refresh: only refresh specific buffer if configured + if staging_state.active and staging_state.target_buffer then + local target_buf = staging_state.target_buffer + if target_buf:is_valid() then + git_buffer_store.dispatch(target_buf, 'sync') + end + return + end + + -- Default behavior: refresh all tracked buffers git_buffer_store.for_each(function(buffer) git_buffer_store.dispatch(buffer, 'sync') end) @@ -181,4 +203,43 @@ git_buffer_store.collect = function() git_buffer_store.dispatch(git_buffer, 'attach') end +-- Performance: API for controlling VGitSync behavior during staging operations +-- Reduces buffer refresh cascade by limiting which buffers get refreshed + +-- Suppress all VGitSync events for a specified duration +-- Useful for batch operations where multiple git index changes occur +-- Usage: git_buffer_store.suppress_sync_for(500) +git_buffer_store.suppress_sync_for = function(ms) + staging_state.suppress_sync = true + vim.defer_fn(function() + staging_state.suppress_sync = false + end, ms) +end + +-- Enable selective refresh mode - only refresh the specified buffer on VGitSync +-- Usage: git_buffer_store.begin_selective_staging(buffer) +git_buffer_store.begin_selective_staging = function(buffer) + staging_state.active = true + staging_state.target_buffer = buffer +end + +-- Disable selective refresh mode, returning to default behavior +git_buffer_store.end_selective_staging = function() + staging_state.active = false + staging_state.target_buffer = nil +end + +-- Convenience wrapper: execute staging function with selective refresh +-- Automatically enables selective mode, runs function, then restores after delay +-- Usage: git_buffer_store.with_selective_staging(buffer, function() ... end) +git_buffer_store.with_selective_staging = function(buffer, stage_fn) + git_buffer_store.begin_selective_staging(buffer) + local ok, err = pcall(stage_fn) + -- Keep selective mode active briefly to catch the filesystem watcher event + vim.defer_fn(function() + git_buffer_store.end_selective_staging() + end, 150) -- Filesystem watcher has 10ms debounce, add margin + if not ok then error(err) end +end + return git_buffer_store