Merge #40220 from justinmk/fixautoread

This commit is contained in:
Justin M. Keyes
2026-06-13 17:37:08 -04:00
committed by GitHub
5 changed files with 107 additions and 143 deletions

View File

@@ -809,12 +809,13 @@ A jump table for the options with a short description can be found at |Q_op|.
*'autoread'* *'ar'* *'noautoread'* *'noar'*
'autoread' 'ar' boolean (default on)
global or local to buffer |global-local|
When a file has been detected to have been changed outside of Vim and
it has not been changed inside of Vim, automatically read it again.
When the file has been deleted this is not done, so you have the text
from before it was deleted. When it appears again then it is read.
Nvim uses file system watchers to detect changes in real-time for all
loaded buffers; see |timestamp| for details.
When a file was changed outside of Nvim, automatically read it again.
Skipped if the file was deleted, so you have the text from before it
was deleted. If the file appears again then it is read. |timestamp|
This is partially driven by OS filewatcher events |uv_fs_event_t|, so
even the current buffer may be updated.
If this option has a local value, use this command to switch back to
using the global value: >vim
set autoread<

View File

@@ -14,7 +14,21 @@ local watchers = {}
--- @type table<integer, uv.uv_timer_t> bufnr -> debounce timer
local timers = {}
local DEBOUNCE_MS = 100
local debounce_ms = 100
--- @private
--- Test-only: override the debounce window so tests can run faster.
--- @param ms integer
function M._set_debounce(ms)
debounce_ms = ms
end
--- @private
--- @param bufnr integer
--- @return boolean
function M._is_watching(bufnr)
return watchers[bufnr] ~= nil
end
--- Returns the effective 'autoread' value for a buffer.
--- 'autoread' is global-local: vim.bo[bufnr].autoread is nil when not set locally,
@@ -84,7 +98,7 @@ local function ensure_watcher(bufnr)
local cancel = watch.watch(name, {}, function(_, change_type)
-- Debounce: restart the same timer on each event, so only the last
-- event in a rapid series (e.g. truncate + write) triggers checktime.
timer:start(DEBOUNCE_MS, 0, function()
timer:start(debounce_ms, 0, function()
vim.schedule(function()
if not vim.api.nvim_buf_is_loaded(bufnr) or not buf_autoread(bufnr) then
return
@@ -102,13 +116,6 @@ local function ensure_watcher(bufnr)
watchers[bufnr] = cancel
end
--- @private
--- @param bufnr integer
--- @return boolean
function M._is_watching(bufnr)
return watchers[bufnr] ~= nil
end
function M.enable()
local group = vim.api.nvim_create_augroup('nvim.autoread', { clear = true })

View File

@@ -168,12 +168,13 @@ vim.o.ai = vim.o.autoindent
vim.bo.autoindent = vim.o.autoindent
vim.bo.ai = vim.bo.autoindent
--- When a file has been detected to have been changed outside of Vim and
--- it has not been changed inside of Vim, automatically read it again.
--- When the file has been deleted this is not done, so you have the text
--- from before it was deleted. When it appears again then it is read.
--- Nvim uses file system watchers to detect changes in real-time for all
--- loaded buffers; see `timestamp` for details.
--- When a file was changed outside of Nvim, automatically read it again.
--- Skipped if the file was deleted, so you have the text from before it
--- was deleted. If the file appears again then it is read. `timestamp`
---
--- This is partially driven by OS filewatcher events `uv_fs_event_t`, so
--- even the current buffer may be updated.
---
--- If this option has a local value, use this command to switch back to
--- using the global value:
---

View File

@@ -301,12 +301,13 @@ local options = {
abbreviation = 'ar',
defaults = true,
desc = [=[
When a file has been detected to have been changed outside of Vim and
it has not been changed inside of Vim, automatically read it again.
When the file has been deleted this is not done, so you have the text
from before it was deleted. When it appears again then it is read.
Nvim uses file system watchers to detect changes in real-time for all
loaded buffers; see |timestamp| for details.
When a file was changed outside of Nvim, automatically read it again.
Skipped if the file was deleted, so you have the text from before it
was deleted. If the file appears again then it is read. |timestamp|
This is partially driven by OS filewatcher events |uv_fs_event_t|, so
even the current buffer may be updated.
If this option has a local value, use this command to switch back to
using the global value: >vim
set autoread<

View File

@@ -9,8 +9,6 @@ local retry = t.retry
local write_file = t.write_file
local sleep = vim.uv.sleep
local testdir = 'Xtest-autoread'
--- Returns true if the autoread module is watching the given buffer
--- (defaults to the current buffer).
local function is_watching(bufnr)
@@ -19,70 +17,88 @@ local function is_watching(bufnr)
end, bufnr)
end
--- Shortens the 'autoread' debounce window so each test doesn't pay the 100ms time-cost.
local function shorten_debounce()
n.exec_lua([[require('nvim.autoread')._set_debounce(10)]])
end
--- Edits a fresh tempfile with the given initial content and asserts the watcher attached.
--- Returns the file path.
local function open_watched(content)
local path = t.tmpname()
write_file(path, content)
command('edit ' .. path)
eq(true, is_watching())
return path
end
describe('autoread file watcher', function()
before_each(function()
n.mkdir_p(testdir)
clear({ args = { '--clean' } })
end)
after_each(function()
n.rmdir(testdir)
shorten_debounce()
end)
it('watches file opened on startup (nvim foo.txt)', function()
local path = testdir .. '/test_startup.txt'
local path = t.tmpname()
write_file(path, 'startup original\n')
-- Spawn nvim with the file passed on the command line. This exercises the
-- boot order: plugins must load before the initial file is read so that
-- the BufReadPost autocmd is registered in time to attach a watcher.
clear({ args = { '--clean', path } })
shorten_debounce()
eq({ 'startup original' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Modify file externally; watcher should already be active.
write_file(path, 'startup changed\n')
retry(nil, 3000, function()
eq({ 'startup changed' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
end)
it('reloads buffer when file changes externally', function()
local path = testdir .. '/test_reload.txt'
write_file(path, 'original content\n')
it('reloads on external change; survives hide; undoable; bdelete stops watch', function()
local path = open_watched('original content\n')
local bufnr = api.nvim_get_current_buf()
command('edit ' .. path)
eq({ 'original content' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Modify file externally
-- 1. Plain external change reloads the visible buffer.
write_file(path, 'new content\n')
-- The watcher + debounce should trigger checktime
retry(nil, 3000, function()
eq({ 'new content' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
-- 2. Hide the buffer; watcher stays attached and still reloads.
command('set hidden')
command('enew')
eq(true, is_watching(bufnr))
write_file(path, 'while hidden\n')
retry(nil, 3000, function()
eq({ 'while hidden' }, api.nvim_buf_get_lines(bufnr, 0, -1, true))
end)
-- 3. The reload is undoable. Done last so the resulting modified state
-- (buffer ≠ disk) doesn't block earlier auto-reload assertions.
command('buffer ' .. bufnr)
command('silent undo')
eq({ 'new content' }, api.nvim_buf_get_lines(0, 0, -1, true))
-- 4. bdelete stops the watcher.
command('enew!')
command('bdelete! ' .. bufnr)
eq(false, is_watching(bufnr))
end)
it('does not reload when buffer has unsaved changes (conflict)', function()
local path = testdir .. '/test_conflict.txt'
write_file(path, 'original\n')
local path = open_watched('original\n')
command('edit ' .. path)
eq(true, is_watching())
-- Make a local change so the buffer is modified
api.nvim_buf_set_lines(0, 0, -1, true, { 'local change' })
eq(true, api.nvim_get_option_value('modified', { buf = 0 }))
-- Modify file externally
write_file(path, 'external change\n')
-- Give watcher time to fire; buffer should NOT be reloaded
-- because it has unsaved changes (autoread only reloads unmodified buffers)
sleep(200)
-- Give the watcher time to fire; the buffer must NOT be reloaded because
-- it has unsaved changes (autoread only reloads unmodified buffers).
sleep(50)
-- Also do a manual checktime to be sure
command('silent! checktime')
-- Buffer should still have local changes (autoread doesn't override modified buffers)
@@ -90,17 +106,14 @@ describe('autoread file watcher', function()
end)
it('tracks autoread option changes', function()
local path = testdir .. '/test_reenable.txt'
write_file(path, 'original\n')
local path = open_watched('original\n')
command('edit ' .. path)
eq(true, is_watching())
command('setlocal noautoread')
eq(false, is_watching())
-- Modify externally while noautoread
write_file(path, 'while disabled\n')
sleep(200)
sleep(50)
eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true))
-- Re-enable autoread
@@ -109,93 +122,55 @@ describe('autoread file watcher', function()
-- Modify again
write_file(path, 'after reenable\n')
retry(nil, 3000, function()
eq({ 'after reenable' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
end)
it('stops watcher on bdelete', function()
local path = testdir .. '/test_bdelete.txt'
write_file(path, 'content\n')
command('edit ' .. path)
local bufnr = api.nvim_get_current_buf()
eq(true, is_watching(bufnr))
command('enew')
command('bdelete ' .. bufnr)
eq(false, is_watching(bufnr))
end)
it('reloads hidden buffer when file changes', function()
local path = testdir .. '/test_hidden.txt'
write_file(path, 'original\n')
command('set hidden')
command('edit ' .. path)
local bufnr = api.nvim_get_current_buf()
eq(true, is_watching(bufnr))
-- Switch to a different buffer (hides the first one)
command('enew')
eq(true, is_watching(bufnr))
-- Modify file externally
write_file(path, 'updated hidden\n')
retry(nil, 3000, function()
eq({ 'updated hidden' }, api.nvim_buf_get_lines(bufnr, 0, -1, true))
end)
end)
it('handles file deletion gracefully', function()
local path = testdir .. '/test_delete.txt'
write_file(path, 'will be deleted\n')
local path = open_watched('will be deleted\n')
command('edit ' .. path)
eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Delete the file
os.remove(path)
retry(nil, 3000, function()
eq(false, is_watching())
end)
-- Buffer content should remain unchanged
-- Buffer content remains unchanged.
eq({ 'will be deleted' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
it('handles rapid changes with debouncing', function()
local path = testdir .. '/test_debounce.txt'
write_file(path, 'v1\n')
it('coalesces rapid changes via debouncing', function()
local path = open_watched('v1\n')
command('edit ' .. path)
eq({ 'v1' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Count buffer reloads triggered by the watcher.
n.exec_lua([[
_G.reloads = 0
vim.api.nvim_create_autocmd('FileChangedShellPost', {
callback = function() _G.reloads = _G.reloads + 1 end,
})
]])
-- Make several rapid changes
-- 4 back-to-back writes well inside one debounce window.
write_file(path, 'v2\n')
write_file(path, 'v3\n')
write_file(path, 'v4\n')
write_file(path, 'final\n')
-- Should eventually settle on final content
retry(nil, 3000, function()
eq({ 'final' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
-- Let any late-arriving event flush, then assert all 4 writes coalesced.
-- Every fs_event restarts the debounce timer, and 4 sub-millisecond
-- write_file calls fit well inside one window, so the timer fires once.
sleep(50)
eq(1, n.exec_lua('return _G.reloads'))
end)
it('detects changes after atomic rename (external editor save)', function()
local path = testdir .. '/test_rename.txt'
write_file(path, 'original\n')
local path = open_watched('original\n')
command('edit ' .. path)
eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Simulate atomic save: write to temp file, rename over target
-- Atomic save: write to temp file, rename over target.
local tmp = path .. '.tmp'
write_file(tmp, 'after rename\n')
assert(vim.uv.fs_rename(tmp, path))
@@ -203,34 +178,13 @@ describe('autoread file watcher', function()
retry(nil, 3000, function()
eq({ 'after rename' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
-- Watcher should have been re-established on the new inode.
-- Watcher re-established on the new inode.
eq(true, is_watching())
-- Verify the watcher still works for subsequent plain writes
-- Subsequent plain writes still reload.
write_file(path, 'second change\n')
retry(nil, 3000, function()
eq({ 'second change' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
end)
it('auto-reload is undoable', function()
local path = testdir .. '/test_undo.txt'
write_file(path, 'original\n')
command('edit ' .. path)
eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Modify externally
write_file(path, 'changed externally\n')
retry(nil, 3000, function()
eq({ 'changed externally' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
-- Undo should restore original content
command('silent undo')
eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
end)