feat(autoread): use filewatchers for OS-driven change detection #37971

Problem:
The 'autoread' option only checks for file changes reactively — on
FocusGained, :checktime, CmdlineEnter, etc. — by polling timestamps.
External changes are not detected until the user interacts with Neovim.

Solution:
Add a core module (runtime/lua/nvim/autoread.lua) enabled from
runtime/plugin/autoread.lua that watches each buffer's file using
vim._watch.watch() (libuv fs_event). On change detection it calls
:checktime, which invokes the existing buf_check_timestamp() logic
for reload/prompt handling. Watchers are managed via autocmds tied
to buffer lifecycle events and respect the 'autoread' option (global
and buffer-local).
This commit is contained in:
Oleksandr Chekhovskyi
2026-06-13 01:25:15 +03:00
committed by GitHub
parent c622b454b5
commit 400f247397
8 changed files with 411 additions and 3 deletions

View File

@@ -1515,6 +1515,12 @@ focus.
If you want to automatically reload a file when it has been changed outside of
Vim, set the 'autoread' option. This doesn't work at the moment you write the
file though, only when the file wasn't changed inside of Vim.
Nvim uses file system watchers to detect external changes in real-time for
all loaded buffers when 'autoread' is set. This means files are reloaded
promptly without waiting for |FocusGained| or |:checktime|. The watchers
are managed automatically: started when a buffer is loaded, restarted after
|:write|, and stopped when the buffer is deleted or 'autoread' is disabled.
*ignore-timestamp*
If you do not want to be asked or automatically reload the file, you can use
this: >

View File

@@ -166,6 +166,8 @@ EDITOR
number of history lines kept above the prompt.
• |v_al| and |v_il| text objects select the whole buffer and the current line
without leading or trailing white space.
• 'autoread' uses file system watchers to detect external changes in
real-time, instead of only on |FocusGained|/|:checktime|.
EVENTS

View File

@@ -813,7 +813,8 @@ A jump table for the options with a short description can be found at |Q_op|.
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.
|timestamp|
Nvim uses file system watchers to detect changes in real-time for all
loaded buffers; see |timestamp| for details.
If this option has a local value, use this command to switch back to
using the global value: >vim
set autoread<

View File

@@ -0,0 +1,149 @@
--- Watches buffer files for external changes using vim._watch.
--- When 'autoread' is set, external changes are detected in real-time
--- instead of only on FocusGained/:checktime.
local uv = vim.uv
local watch = vim._watch
local nvim_on = require('vim._core.util').nvim_on
local M = {}
--- @type table<integer, fun()> bufnr -> cancel function
local watchers = {}
--- @type table<integer, uv.uv_timer_t> bufnr -> debounce timer
local timers = {}
local DEBOUNCE_MS = 100
--- Returns the effective 'autoread' value for a buffer.
--- 'autoread' is global-local: vim.bo[bufnr].autoread is nil when not set locally,
--- so we must fall back to the global value.
--- @param bufnr integer
--- @return boolean
local function buf_autoread(bufnr)
local local_val = vim.bo[bufnr].autoread
if local_val ~= nil then
return local_val
end
return vim.go.autoread
end
--- Returns true if the buffer should be watched.
--- @param bufnr integer
--- @return boolean
local function should_watch(bufnr)
if not vim.api.nvim_buf_is_loaded(bufnr) then
return false
end
-- Skip special buffers (terminal, help, quickfix, etc.)
if vim.bo[bufnr].buftype ~= '' then
return false
end
-- Must have a file name that exists on disk
local name = vim.api.nvim_buf_get_name(bufnr)
if name == '' or not uv.fs_stat(name) then
return false
end
if not buf_autoread(bufnr) then
return false
end
return true
end
--- Stops and cleans up the watcher for a buffer.
--- @param bufnr integer
local function stop_watcher(bufnr)
local cancel = watchers[bufnr]
if cancel then
cancel()
watchers[bufnr] = nil
end
local timer = timers[bufnr]
if timer then
timer:stop()
timer:close()
timers[bufnr] = nil
end
end
--- Ensures the buffer has an active file watcher if appropriate, or stops
--- an existing one if the buffer should no longer be watched.
--- @param bufnr integer
local function ensure_watcher(bufnr)
stop_watcher(bufnr)
if not should_watch(bufnr) then
return
end
local name = vim.api.nvim_buf_get_name(bufnr)
local timer = assert(uv.new_timer())
timers[bufnr] = timer
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()
vim.schedule(function()
if not vim.api.nvim_buf_is_loaded(bufnr) or not buf_autoread(bufnr) then
return
end
vim.cmd.checktime(bufnr)
-- On rename events (e.g. atomic save by another editor), the watcher
-- is now stale (watching the old inode). Re-establish it.
if change_type ~= watch.FileChangeType.Changed then
ensure_watcher(bufnr)
end
end)
end)
end)
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 })
-- (Re)start watcher when a file is loaded or written.
nvim_on({ 'BufReadPost', 'BufWritePost' }, group, function(args)
ensure_watcher(args.buf)
end)
-- Stop watcher when buffer is unloaded or wiped out.
nvim_on({ 'BufUnload', 'BufWipeout' }, group, function(args)
stop_watcher(args.buf)
end)
-- Clean up all watchers on exit to avoid dangling handles in the event loop.
nvim_on('VimLeavePre', group, function()
for bufnr in pairs(watchers) do
stop_watcher(bufnr)
end
end)
-- React to 'autoread' option changes.
nvim_on('OptionSet', group, { pattern = 'autoread' }, function()
if vim.v.option_type == 'global' then
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
ensure_watcher(bufnr)
end
else
ensure_watcher(vim.api.nvim_get_current_buf())
end
end)
-- Attach to buffers that were already loaded before enable() ran.
for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do
ensure_watcher(bufnr)
end
end
return M

View File

@@ -172,7 +172,8 @@ vim.bo.ai = vim.bo.autoindent
--- 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.
--- `timestamp`
--- Nvim uses file system watchers to detect changes in real-time for all
--- loaded buffers; see `timestamp` for details.
--- If this option has a local value, use this command to switch back to
--- using the global value:
---

View File

@@ -0,0 +1,12 @@
-- File-watcher backing for the 'autoread' option.
-- Lives here (not in vim/_core/defaults.lua) so that `-u NONE` / `--noplugin`
-- skip it: the BufReadPost/BufWritePost autocmds it registers would otherwise
-- show up in every test that inspects the autocmd list. Matches the pattern
-- used by runtime/plugin/matchparen.lua.
if vim.g.loaded_autoread ~= nil then
return
end
vim.g.loaded_autoread = 1
require('nvim.autoread').enable()

View File

@@ -305,7 +305,8 @@ local options = {
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.
|timestamp|
Nvim uses file system watchers to detect changes in real-time for all
loaded buffers; see |timestamp| for details.
If this option has a local value, use this command to switch back to
using the global value: >vim
set autoread<

View File

@@ -0,0 +1,236 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
local clear = n.clear
local command = n.command
local eq = t.eq
local api = n.api
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)
return n.exec_lua(function(b)
return require('nvim.autoread')._is_watching(b or vim.api.nvim_get_current_buf())
end, bufnr)
end
describe('autoread file watcher', function()
before_each(function()
n.mkdir_p(testdir)
clear({ args = { '--clean' } })
end)
after_each(function()
n.rmdir(testdir)
end)
it('watches file opened on startup (nvim foo.txt)', function()
local path = testdir .. '/test_startup.txt'
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 } })
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')
command('edit ' .. path)
eq({ 'original content' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Modify file externally
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)
end)
it('does not reload when buffer has unsaved changes (conflict)', function()
local path = testdir .. '/test_conflict.txt'
write_file(path, '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)
-- Also do a manual checktime to be sure
command('silent! checktime')
-- Buffer should still have local changes (autoread doesn't override modified buffers)
eq({ 'local change' }, api.nvim_buf_get_lines(0, 0, -1, true))
end)
it('tracks autoread option changes', function()
local path = testdir .. '/test_reenable.txt'
write_file(path, '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)
eq({ 'original' }, api.nvim_buf_get_lines(0, 0, -1, true))
-- Re-enable autoread
command('setlocal autoread')
eq(true, is_watching())
-- 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')
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
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')
command('edit ' .. path)
eq({ 'v1' }, api.nvim_buf_get_lines(0, 0, -1, true))
eq(true, is_watching())
-- Make several rapid changes
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)
end)
it('detects changes after atomic rename (external editor save)', function()
local path = testdir .. '/test_rename.txt'
write_file(path, '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
local tmp = path .. '.tmp'
write_file(tmp, 'after rename\n')
assert(vim.uv.fs_rename(tmp, path))
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.
eq(true, is_watching())
-- Verify the watcher still works for subsequent plain writes
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)