1 # Copyright (C) 2008-2024 David Aguilar
2 # Copyright (C) 2015 Daniel Harding
3 """Filesystem monitor for Linux and Windows
5 Linux monitoring uses using inotify.
6 Windows monitoring uses pywin32 and the ReadDirectoryChanges function.
13 from threading
import Lock
15 from qtpy
import QtCore
16 from qtpy
.QtCore
import Signal
22 from .compat
import bchr
24 from .interaction
import Interaction
43 elif utils
.is_linux():
52 class _Monitor(QtCore
.QObject
):
53 files_changed
= Signal()
54 config_changed
= Signal()
56 def __init__(self
, context
, thread_class
):
57 QtCore
.QObject
.__init
__(self
)
58 self
.context
= context
59 self
._thread
_class
= thread_class
63 if self
._thread
_class
is not None:
64 assert self
._thread
is None
65 self
._thread
= self
._thread
_class
(self
.context
, self
)
69 if self
._thread
_class
is not None:
70 assert self
._thread
is not None
76 if self
._thread
is not None:
77 self
._thread
.refresh()
80 class _BaseThread(QtCore
.QThread
):
81 #: The delay, in milliseconds, between detecting file system modification
82 #: and triggering the 'files_changed' signal, to coalesce multiple
83 #: modifications into a single signal.
84 _NOTIFICATION_DELAY
= 888
86 def __init__(self
, context
, monitor
):
87 QtCore
.QThread
.__init
__(self
)
88 self
.context
= context
89 self
._monitor
= monitor
91 self
._use
_check
_ignore
= version
.check_git(context
, 'check-ignore')
92 self
._force
_notify
= False
93 self
._force
_config
= False
94 self
._file
_paths
= set()
98 return self
._force
_notify
or self
._file
_paths
or self
._force
_config
101 """Do any housekeeping necessary in response to repository changes."""
105 """Notifies all observers"""
108 if self
._force
_config
:
110 if self
._force
_notify
:
112 elif self
._file
_paths
:
113 proc
= core
.start_command(
114 ['git', 'check-ignore', '--verbose', '--non-matching', '-z', '--stdin']
116 path_list
= bchr(0).join(core
.encode(path
) for path
in self
._file
_paths
)
117 out
, _
= proc
.communicate(path_list
)
121 # Each output record is four fields separated by NULL
122 # characters (records are also separated by NULL characters):
123 # <source> <NULL> <linenum> <NULL> <pattern> <NULL> <pathname>
124 # For paths which are not ignored, all fields will be empty
125 # except for <pathname>. So to see if we have any non-ignored
126 # files, we simply check every fourth field to see if any of
128 source_fields
= out
.split(bchr(0))[0:-1:4]
129 do_notify
= not all(source_fields
)
130 self
._force
_notify
= False
131 self
._force
_config
= False
132 self
._file
_paths
= set()
134 # "files changed" is a bigger hammer than "config changed".
135 # and is a superset relative to what is done in response to the
136 # signal. Thus, the "elif" below avoids repeated work that
137 # would be done if it were a simple "if" check instead.
139 self
._monitor
.files_changed
.emit()
141 self
._monitor
.config_changed
.emit()
144 def _log_enabled_message():
145 msg
= N_('File system change monitoring: enabled.\n')
149 if AVAILABLE
== 'inotify':
151 class _InotifyThread(_BaseThread
):
154 | inotify
.IN_CLOSE_WRITE
158 | inotify
.IN_MOVED_FROM
159 | inotify
.IN_MOVED_TO
161 _ADD_MASK
= _TRIGGER_MASK | inotify
.IN_EXCL_UNLINK | inotify
.IN_ONLYDIR
163 def __init__(self
, context
, monitor
):
164 _BaseThread
.__init
__(self
, context
, monitor
)
166 worktree
= git
.worktree()
167 if worktree
is not None:
168 worktree
= core
.abspath(worktree
)
169 self
._worktree
= worktree
170 self
._git
_dir
= git
.git_path()
172 self
._inotify
_fd
= None
175 self
._worktree
_wd
_to
_path
_map
= {}
176 self
._worktree
_path
_to
_wd
_map
= {}
177 self
._git
_dir
_wd
_to
_path
_map
= {}
178 self
._git
_dir
_path
_to
_wd
_map
= {}
179 self
._git
_dir
_wd
= None
182 def _log_out_of_wds_message():
184 'File system change monitoring: disabled because the'
185 ' limit on the total number of inotify watches was'
186 ' reached. You may be able to increase the limit on'
187 ' the number of watches by running:\n'
189 ' echo fs.inotify.max_user_watches=100000 |'
190 ' sudo tee -a /etc/sysctl.conf &&'
199 self
._inotify
_fd
= inotify
.init()
201 self
._inotify
_fd
= None
202 self
._running
= False
203 if e
.errno
== errno
.EMFILE
:
204 self
._log
_out
_of
_wds
_message
()
206 self
._pipe
_r
, self
._pipe
_w
= os
.pipe()
208 # pylint: disable=no-member
209 poll_obj
= select
.poll()
210 poll_obj
.register(self
._inotify
_fd
, select
.POLLIN
)
211 poll_obj
.register(self
._pipe
_r
, select
.POLLIN
)
216 self
._log
_enabled
_message
()
217 self
._process
_events
(poll_obj
)
221 def _process_events(self
, poll_obj
):
224 timeout
= self
._NOTIFICATION
_DELAY
228 events
= poll_obj
.poll(timeout
)
229 # pylint: disable=duplicate-except
233 if not self
._running
:
239 if fd
== self
._inotify
_fd
:
240 self
._handle
_events
()
242 def _close_fds(self
):
244 if self
._inotify
_fd
is not None:
245 os
.close(self
._inotify
_fd
)
246 self
._inotify
_fd
= None
247 if self
._pipe
_r
is not None:
248 os
.close(self
._pipe
_r
)
250 os
.close(self
._pipe
_w
)
258 if self
._inotify
_fd
is None:
260 context
= self
.context
262 if self
._worktree
is not None:
264 os
.path
.dirname(os
.path
.join(self
._worktree
, path
))
265 for path
in gitcmds
.tracked_files(context
)
267 self
._refresh
_watches
(
269 self
._worktree
_wd
_to
_path
_map
,
270 self
._worktree
_path
_to
_wd
_map
,
273 git_dirs
.add(self
._git
_dir
)
274 for dirpath
, _
, _
in core
.walk(os
.path
.join(self
._git
_dir
, 'refs')):
275 git_dirs
.add(dirpath
)
276 self
._refresh
_watches
(
277 git_dirs
, self
._git
_dir
_wd
_to
_path
_map
, self
._git
_dir
_path
_to
_wd
_map
279 self
._git
_dir
_wd
= self
._git
_dir
_path
_to
_wd
_map
.get(self
._git
_dir
)
281 if e
.errno
in (errno
.ENOSPC
, errno
.EMFILE
):
282 self
._log
_out
_of
_wds
_message
()
283 self
._running
= False
287 def _refresh_watches(self
, paths_to_watch
, wd_to_path_map
, path_to_wd_map
):
288 watched_paths
= set(path_to_wd_map
)
289 for path
in watched_paths
- paths_to_watch
:
290 wd
= path_to_wd_map
.pop(path
)
291 wd_to_path_map
.pop(wd
)
293 inotify
.rm_watch(self
._inotify
_fd
, wd
)
295 if e
.errno
== errno
.EINVAL
:
296 # This error can occur if the target of the watch was
297 # removed on the filesystem before we call
298 # inotify.rm_watch() so ignore it.
301 for path
in paths_to_watch
- watched_paths
:
303 wd
= inotify
.add_watch(
304 self
._inotify
_fd
, core
.encode(path
), self
._ADD
_MASK
306 except PermissionError
:
309 if e
.errno
in (errno
.ENOENT
, errno
.ENOTDIR
):
310 # These two errors should only occur as a result of
311 # race conditions: the first if the directory
312 # referenced by path was removed or renamed before the
313 # call to inotify.add_watch(); the second if the
314 # directory referenced by path was replaced with a file
315 # before the call to inotify.add_watch(). Therefore we
316 # simply ignore them.
319 wd_to_path_map
[wd
] = path
320 path_to_wd_map
[path
] = wd
322 def _check_event(self
, wd
, mask
, name
):
323 if mask
& inotify
.IN_Q_OVERFLOW
:
324 self
._force
_notify
= True
325 elif not mask
& self
._TRIGGER
_MASK
:
327 elif mask
& inotify
.IN_ISDIR
:
329 elif wd
in self
._worktree
_wd
_to
_path
_map
:
330 if self
._use
_check
_ignore
and name
:
332 self
._worktree
_wd
_to
_path
_map
[wd
], core
.decode(name
)
334 self
._file
_paths
.add(path
)
336 self
._force
_notify
= True
337 elif wd
== self
._git
_dir
_wd
:
338 name
= core
.decode(name
)
339 if name
in ('HEAD', 'index'):
340 self
._force
_notify
= True
341 elif name
== 'config':
342 self
._force
_config
= True
343 elif wd
in self
._git
_dir
_wd
_to
_path
_map
and not core
.decode(name
).endswith(
346 self
._force
_notify
= True
348 def _handle_events(self
):
349 for wd
, mask
, _
, name
in inotify
.read_events(self
._inotify
_fd
):
350 if not self
._force
_notify
:
351 self
._check
_event
(wd
, mask
, name
)
354 self
._running
= False
356 if self
._pipe
_w
is not None:
357 os
.write(self
._pipe
_w
, bchr(0))
361 if AVAILABLE
== 'pywin32':
364 def __init__(self
, path
, flags
):
371 self
.handle
= win32file
.CreateFileW(
373 0x0001, # FILE_LIST_DIRECTORY
374 win32con
.FILE_SHARE_READ | win32con
.FILE_SHARE_WRITE
,
376 win32con
.OPEN_EXISTING
,
377 win32con
.FILE_FLAG_BACKUP_SEMANTICS | win32con
.FILE_FLAG_OVERLAPPED
,
381 self
.buffer = win32file
.AllocateReadBuffer(8192)
382 self
.event
= win32event
.CreateEvent(None, True, False, None)
383 self
.overlapped
= pywintypes
.OVERLAPPED()
384 self
.overlapped
.hEvent
= self
.event
386 except Exception: # pylint: disable=broad-exception-caught,broad-except
389 def append(self
, events
):
390 """Append our event to the events list when valid"""
391 if self
.event
is not None:
392 events
.append(self
.event
)
395 if self
.handle
is None:
397 win32file
.ReadDirectoryChangesW(
398 self
.handle
, self
.buffer, True, self
.flags
, self
.overlapped
402 if self
.handle
is None or self
.event
is None:
404 if win32event
.WaitForSingleObject(self
.event
, 0) == win32event
.WAIT_TIMEOUT
:
407 nbytes
= win32file
.GetOverlappedResult(
408 self
.handle
, self
.overlapped
, False
410 result
= win32file
.FILE_NOTIFY_INFORMATION(self
.buffer, nbytes
)
415 if self
.handle
is not None:
416 win32file
.CancelIo(self
.handle
)
417 win32file
.CloseHandle(self
.handle
)
418 if self
.event
is not None:
419 win32file
.CloseHandle(self
.event
)
421 class _Win32Thread(_BaseThread
):
423 win32con
.FILE_NOTIFY_CHANGE_FILE_NAME
424 | win32con
.FILE_NOTIFY_CHANGE_DIR_NAME
425 | win32con
.FILE_NOTIFY_CHANGE_ATTRIBUTES
426 | win32con
.FILE_NOTIFY_CHANGE_SIZE
427 | win32con
.FILE_NOTIFY_CHANGE_LAST_WRITE
428 | win32con
.FILE_NOTIFY_CHANGE_SECURITY
431 def __init__(self
, context
, monitor
):
432 _BaseThread
.__init
__(self
, context
, monitor
)
434 worktree
= git
.worktree()
435 if worktree
is not None:
436 worktree
= self
._transform
_path
(core
.abspath(worktree
))
437 self
._worktree
= worktree
438 self
._worktree
_watch
= None
439 self
._git
_dir
= self
._transform
_path
(core
.abspath(git
.git_path()))
440 self
._git
_dir
_watch
= None
441 self
._stop
_event
_lock
= Lock()
442 self
._stop
_event
= None
445 def _transform_path(path
):
446 return path
.replace('\\', '/').lower()
450 with self
._stop
_event
_lock
:
451 self
._stop
_event
= win32event
.CreateEvent(None, True, False, None)
453 events
= [self
._stop
_event
]
455 if self
._worktree
is not None:
456 self
._worktree
_watch
= _Win32Watch(self
._worktree
, self
._FLAGS
)
457 self
._worktree
_watch
.append(events
)
459 self
._git
_dir
_watch
= _Win32Watch(self
._git
_dir
, self
._FLAGS
)
460 self
._git
_dir
_watch
.append(events
)
462 self
._log
_enabled
_message
()
466 timeout
= self
._NOTIFICATION
_DELAY
468 timeout
= win32event
.INFINITE
469 status
= win32event
.WaitForMultipleObjects(events
, False, timeout
)
470 if not self
._running
:
472 if status
== win32event
.WAIT_TIMEOUT
:
475 self
._handle
_results
()
477 with self
._stop
_event
_lock
:
478 if self
._stop
_event
is not None:
479 win32file
.CloseHandle(self
._stop
_event
)
480 self
._stop
_event
= None
481 if self
._worktree
_watch
is not None:
482 self
._worktree
_watch
.close()
483 if self
._git
_dir
_watch
is not None:
484 self
._git
_dir
_watch
.close()
486 def _handle_results(self
):
487 if self
._worktree
_watch
is not None:
488 for _
, path
in self
._worktree
_watch
.read():
489 if not self
._running
:
491 if self
._force
_notify
:
493 path
= self
._worktree
+ '/' + self
._transform
_path
(path
)
495 path
!= self
._git
_dir
496 and not path
.startswith(self
._git
_dir
+ '/')
497 and not os
.path
.isdir(path
)
499 if self
._use
_check
_ignore
:
500 self
._file
_paths
.add(path
)
502 self
._force
_notify
= True
503 for _
, path
in self
._git
_dir
_watch
.read():
504 if not self
._running
:
506 if self
._force
_notify
:
508 path
= self
._transform
_path
(path
)
509 if path
.endswith('.lock'):
512 self
._force
_config
= True
514 if path
== 'head' or path
== 'index' or path
.startswith('refs/'):
515 self
._force
_notify
= True
518 self
._running
= False
519 with self
._stop
_event
_lock
:
520 if self
._stop
_event
is not None:
521 win32event
.SetEvent(self
._stop
_event
)
528 if not cfg
.get('cola.inotify', default
=True):
530 'File system change monitoring: disabled because'
531 ' "cola.inotify" is false.\n'
534 elif AVAILABLE
== 'inotify':
535 thread_class
= _InotifyThread
536 elif AVAILABLE
== 'pywin32':
537 thread_class
= _Win32Thread
541 'File system change monitoring: disabled because pywin32'
542 ' is not installed.\n'
545 elif utils
.is_linux():
547 'File system change monitoring: disabled because libc'
548 ' does not support the inotify system calls.\n'
551 return _Monitor(context
, thread_class
)