File: fsmonitor.py

package info (click to toggle)
git-cola 4.13.0-1
  • links: PTS
  • area: main
  • in suites: sid
  • size: 6,480 kB
  • sloc: python: 36,938; sh: 304; makefile: 223; xml: 100; tcl: 62
file content (572 lines) | stat: -rw-r--r-- 20,673 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# Copyright (C) 2008-2024 David Aguilar
# Copyright (C) 2015 Daniel Harding
"""Filesystem monitor for Linux and Windows

Linux monitoring uses using inotify.
Windows monitoring uses pywin32 and the ReadDirectoryChanges function.

"""
import errno
import os
import os.path
import select
from threading import Lock

from qtpy import QtCore
from qtpy.QtCore import Signal

from . import utils
from . import core
from . import gitcmds
from . import version
from .compat import bchr
from .i18n import N_
from .interaction import Interaction

AVAILABLE = None

pywintypes = None
win32file = None
win32con = None
win32event = None
if utils.is_win32():
    try:
        import pywintypes
        import win32con
        import win32event
        import win32file

        AVAILABLE = 'pywin32'
    except ImportError:
        pass

elif utils.is_linux():
    try:
        from . import inotify
    except ImportError:
        pass
    else:
        AVAILABLE = 'inotify'


class _Monitor(QtCore.QObject):
    files_changed = Signal()
    config_changed = Signal()

    def __init__(self, context, thread_class):
        QtCore.QObject.__init__(self)
        self.context = context
        self._thread_class = thread_class
        self._thread = None

    def start(self):
        if self._thread_class is not None:
            assert self._thread is None
            self._thread = self._thread_class(self.context, self)
            self._thread.start()

    def stop(self):
        if self._thread_class is not None:
            assert self._thread is not None
            self._thread.stop()
            self._thread.wait()
            self._thread = None

    def refresh(self):
        if self._thread is not None:
            self._thread.refresh()


class _BaseThread(QtCore.QThread):
    #: The delay, in milliseconds, between detecting file system modification
    #: and triggering the 'files_changed' signal, to coalesce multiple
    #: modifications into a single signal.
    _NOTIFICATION_DELAY = 888

    def __init__(self, context, monitor):
        QtCore.QThread.__init__(self)
        self.context = context
        self._monitor = monitor
        self._running = True
        self._use_check_ignore = version.check_git(context, 'check-ignore')
        self._force_notify = False
        self._force_config = False
        self._file_paths = set()

    @property
    def _pending(self):
        return self._force_notify or self._file_paths or self._force_config

    def refresh(self):
        """Do any housekeeping necessary in response to repository changes."""
        return

    def notify(self):
        """Notifies all observers"""
        do_notify = False
        do_config = False
        if self._force_config:
            do_config = True
        if self._force_notify:
            do_notify = True
        elif self._file_paths:
            proc = core.start_command(
                ['git', 'check-ignore', '--verbose', '--non-matching', '-z', '--stdin']
            )
            path_list = bchr(0).join(core.encode(path) for path in self._file_paths)
            out, _ = proc.communicate(path_list)
            if proc.returncode:
                do_notify = True
            else:
                # Each output record is four fields separated by NULL
                # characters (records are also separated by NULL characters):
                # <source> <NULL> <linenum> <NULL> <pattern> <NULL> <pathname>
                # For paths which are not ignored, all fields will be empty
                # except for <pathname>.  So to see if we have any non-ignored
                # files, we simply check every fourth field to see if any of
                # them are empty.
                source_fields = out.split(bchr(0))[0:-1:4]
                do_notify = not all(source_fields)
        self._force_notify = False
        self._force_config = False
        self._file_paths = set()

        # "files changed" is a bigger hammer than "config changed".
        # and is a superset relative to what is done in response to the
        # signal.  Thus, the "elif" below avoids repeated work that
        # would be done if it were a simple "if" check instead.
        if do_notify:
            self._monitor.files_changed.emit()
        elif do_config:
            self._monitor.config_changed.emit()

    @staticmethod
    def _log_enabled_message():
        msg = N_('File system change monitoring: enabled.\n')
        Interaction.log(msg)


if AVAILABLE == 'inotify':

    class _InotifyThread(_BaseThread):
        _TRIGGER_MASK = (
            inotify.IN_ATTRIB
            | inotify.IN_CLOSE_WRITE
            | inotify.IN_CREATE
            | inotify.IN_DELETE
            | inotify.IN_MODIFY
            | inotify.IN_MOVED_FROM
            | inotify.IN_MOVED_TO
        )
        _ADD_MASK = _TRIGGER_MASK | inotify.IN_EXCL_UNLINK | inotify.IN_ONLYDIR

        def __init__(self, context, monitor):
            _BaseThread.__init__(self, context, monitor)
            git = context.git
            worktree = git.worktree()
            if worktree is not None:
                worktree = core.abspath(worktree)
            self._worktree = worktree
            self._git_dir = git.git_path()
            self._lock = Lock()
            self._inotify_fd = None
            self._pipe_r = None
            self._pipe_w = None
            self._worktree_wd_to_path_map = {}
            self._worktree_path_to_wd_map = {}
            self._git_dir_wd_to_path_map = {}
            self._git_dir_path_to_wd_map = {}
            self._git_dir_wd = None

        @staticmethod
        def _log_out_of_wds_message():
            msg = N_(
                'File system change monitoring: disabled because the'
                ' limit on the total number of inotify watches was'
                ' reached.  You may be able to increase the limit on'
                ' the number of watches by running:\n'
                '\n'
                '    echo fs.inotify.max_user_watches=100000 |'
                ' sudo tee -a /etc/sysctl.conf &&'
                ' sudo sysctl -p\n'
            )
            Interaction.log(msg)

        def run(self):
            try:
                with self._lock:
                    try:
                        self._inotify_fd = inotify.init()
                    except OSError as e:
                        self._inotify_fd = None
                        self._running = False
                        if e.errno == errno.EMFILE:
                            self._log_out_of_wds_message()
                        return
                    self._pipe_r, self._pipe_w = os.pipe()

                poll_obj = select.poll()
                poll_obj.register(self._inotify_fd, select.POLLIN)
                poll_obj.register(self._pipe_r, select.POLLIN)

                self.refresh()

                if self._running:
                    self._log_enabled_message()
                    self._process_events(poll_obj)
            finally:
                self._close_fds()

        def _process_events(self, poll_obj):
            while self._running:
                if self._pending:
                    timeout = self._NOTIFICATION_DELAY
                else:
                    timeout = None
                try:
                    events = poll_obj.poll(timeout)
                except OSError:
                    continue
                else:
                    if not self._running:
                        break
                    if not events:
                        self.notify()
                    else:
                        for fd, _ in events:
                            if fd == self._inotify_fd:
                                self._handle_events()

        def _close_fds(self):
            with self._lock:
                if self._inotify_fd is not None:
                    os.close(self._inotify_fd)
                    self._inotify_fd = None
                if self._pipe_r is not None:
                    os.close(self._pipe_r)
                    self._pipe_r = None
                    os.close(self._pipe_w)
                    self._pipe_w = None

        def refresh(self):
            with self._lock:
                self._refresh()

        def _refresh(self):
            if self._inotify_fd is None:
                return
            context = self.context
            try:
                if self._worktree is not None:
                    tracked_dirs = {
                        os.path.dirname(os.path.join(self._worktree, path))
                        for path in gitcmds.tracked_files(context)
                    }
                    self._refresh_watches(
                        tracked_dirs,
                        self._worktree_wd_to_path_map,
                        self._worktree_path_to_wd_map,
                    )
                git_dirs = set()
                git_dirs.add(self._git_dir)
                for dirpath, _, _ in core.walk(os.path.join(self._git_dir, 'refs')):
                    git_dirs.add(dirpath)
                self._refresh_watches(
                    git_dirs, self._git_dir_wd_to_path_map, self._git_dir_path_to_wd_map
                )
                self._git_dir_wd = self._git_dir_path_to_wd_map.get(self._git_dir)
            except OSError as e:
                if e.errno in (errno.ENOSPC, errno.EMFILE):
                    self._log_out_of_wds_message()
                    self._running = False
                else:
                    raise

        def _refresh_watches(self, paths_to_watch, wd_to_path_map, path_to_wd_map):
            watched_paths = set(path_to_wd_map)
            for path in watched_paths - paths_to_watch:
                wd = path_to_wd_map.pop(path)
                wd_to_path_map.pop(wd)
                try:
                    inotify.rm_watch(self._inotify_fd, wd)
                except OSError as e:
                    if e.errno == errno.EINVAL:
                        # This error can occur if the target of the watch was
                        # removed on the filesystem before we call
                        # inotify.rm_watch() so ignore it.
                        continue
                    raise e
            for path in paths_to_watch - watched_paths:
                try:
                    wd = inotify.add_watch(
                        self._inotify_fd, core.encode(path), self._ADD_MASK
                    )
                except PermissionError:
                    continue
                except OSError as e:
                    if e.errno in (errno.ENOENT, errno.ENOTDIR):
                        # These two errors should only occur as a result of
                        # race conditions:  the first if the directory
                        # referenced by path was removed or renamed before the
                        # call to inotify.add_watch(); the second if the
                        # directory referenced by path was replaced with a file
                        # before the call to inotify.add_watch().  Therefore we
                        # simply ignore them.
                        continue
                    raise e
                wd_to_path_map[wd] = path
                path_to_wd_map[path] = wd

        def _check_event(self, wd, mask, name):
            if mask & inotify.IN_Q_OVERFLOW:
                self._force_notify = True
            elif not mask & self._TRIGGER_MASK:
                pass
            elif mask & inotify.IN_ISDIR:
                pass
            elif wd in self._worktree_wd_to_path_map:
                if self._use_check_ignore and name:
                    path = os.path.join(
                        self._worktree_wd_to_path_map[wd], core.decode(name)
                    )
                    self._file_paths.add(path)
                else:
                    self._force_notify = True
            elif wd == self._git_dir_wd:
                name = core.decode(name)
                if name in ('HEAD', 'index'):
                    self._force_notify = True
                elif name == 'config':
                    self._force_config = True
            elif wd in self._git_dir_wd_to_path_map and not core.decode(name).endswith(
                '.lock'
            ):
                self._force_notify = True

        def _handle_events(self):
            for wd, mask, _, name in inotify.read_events(self._inotify_fd):
                if not self._force_notify:
                    self._check_event(wd, mask, name)

        def stop(self):
            self._running = False
            with self._lock:
                if self._pipe_w is not None:
                    os.write(self._pipe_w, bchr(0))
            self.wait()


if AVAILABLE == 'pywin32':

    class _Win32Watch:
        def __init__(self, path, flags):
            self.flags = flags

            self.handle = None
            self.event = None

            try:
                self.handle = win32file.CreateFileW(
                    path,
                    0x0001,  # FILE_LIST_DIRECTORY
                    win32con.FILE_SHARE_READ | win32con.FILE_SHARE_WRITE,
                    None,
                    win32con.OPEN_EXISTING,
                    win32con.FILE_FLAG_BACKUP_SEMANTICS | win32con.FILE_FLAG_OVERLAPPED,
                    None,
                )

                self.buffer = win32file.AllocateReadBuffer(8192)
                self.event = win32event.CreateEvent(None, True, False, None)
                self.overlapped = pywintypes.OVERLAPPED()
                self.overlapped.hEvent = self.event
                self._start()
            except Exception:
                self.close()

        def append(self, events):
            """Append our event to the events list when valid"""
            if self.event is not None:
                events.append(self.event)

        def _start(self):
            if self.handle is None:
                return
            try:
                win32file.ReadDirectoryChangesW(
                    self.handle, self.buffer, True, self.flags, self.overlapped
                )
            except pywintypes.error:
                pass

        def read(self):
            if self.handle is None or self.event is None:
                return []
            if win32event.WaitForSingleObject(self.event, 0) == win32event.WAIT_TIMEOUT:
                result = []
            else:
                nbytes = win32file.GetOverlappedResult(
                    self.handle, self.overlapped, False
                )
                result = win32file.FILE_NOTIFY_INFORMATION(self.buffer, nbytes)
                self._start()
            return result

        def close(self):
            if self.handle is not None:
                try:
                    win32file.CancelIo(self.handle)
                except pywintypes.error:
                    pass
                try:
                    win32file.CloseHandle(self.handle)
                except pywintypes.error:
                    pass
            if self.event is not None:
                try:
                    win32file.CloseHandle(self.event)
                except pywintypes.error:
                    pass

    class _Win32Thread(_BaseThread):
        _FLAGS = (
            win32con.FILE_NOTIFY_CHANGE_FILE_NAME
            | win32con.FILE_NOTIFY_CHANGE_DIR_NAME
            | win32con.FILE_NOTIFY_CHANGE_ATTRIBUTES
            | win32con.FILE_NOTIFY_CHANGE_SIZE
            | win32con.FILE_NOTIFY_CHANGE_LAST_WRITE
            | win32con.FILE_NOTIFY_CHANGE_SECURITY
        )

        def __init__(self, context, monitor):
            _BaseThread.__init__(self, context, monitor)
            git = context.git
            worktree = git.worktree()
            if worktree is not None:
                worktree = self._transform_path(core.abspath(worktree))
            self._worktree = worktree
            self._worktree_watch = None
            self._git_dir = self._transform_path(core.abspath(git.git_path()))
            self._git_dir_watch = None
            self._stop_event_lock = Lock()
            self._stop_event = None

        @staticmethod
        def _transform_path(path):
            return path.replace('\\', '/').lower()

        def run(self):
            try:
                with self._stop_event_lock:
                    self._stop_event = win32event.CreateEvent(None, True, False, None)

                events = [self._stop_event]

                if self._worktree is not None:
                    self._worktree_watch = _Win32Watch(self._worktree, self._FLAGS)
                    self._worktree_watch.append(events)

                self._git_dir_watch = _Win32Watch(self._git_dir, self._FLAGS)
                self._git_dir_watch.append(events)

                self._log_enabled_message()

                while self._running:
                    if self._pending:
                        timeout = self._NOTIFICATION_DELAY
                    else:
                        timeout = win32event.INFINITE
                    try:
                        status = win32event.WaitForMultipleObjects(
                            events, False, timeout
                        )
                    except pywintypes.error:
                        self._running = False
                    if not self._running:
                        break
                    if status == win32event.WAIT_TIMEOUT:
                        self.notify()
                    else:
                        self._handle_results()
            finally:
                with self._stop_event_lock:
                    if self._stop_event is not None:
                        try:
                            win32file.CloseHandle(self._stop_event)
                        except pywintypes.error:
                            pass
                        self._stop_event = None
                if self._worktree_watch is not None:
                    self._worktree_watch.close()
                if self._git_dir_watch is not None:
                    self._git_dir_watch.close()

        def _handle_results(self):
            if self._worktree_watch is not None:
                for _, path in self._worktree_watch.read():
                    if not self._running:
                        break
                    if self._force_notify:
                        continue
                    path = self._worktree + '/' + self._transform_path(path)
                    if (
                        path != self._git_dir
                        and not path.startswith(self._git_dir + '/')
                        and not os.path.isdir(path)
                    ):
                        if self._use_check_ignore:
                            self._file_paths.add(path)
                        else:
                            self._force_notify = True
            for _, path in self._git_dir_watch.read():
                if not self._running:
                    break
                if self._force_notify:
                    continue
                path = self._transform_path(path)
                if path.endswith('.lock'):
                    continue
                if path == 'config':
                    self._force_config = True
                    continue
                if path == 'head' or path == 'index' or path.startswith('refs/'):
                    self._force_notify = True

        def stop(self):
            self._running = False
            with self._stop_event_lock:
                if self._stop_event is not None:
                    try:
                        win32event.SetEvent(self._stop_event)
                    except pywintypes.error:
                        pass
            self.wait()


def create(context):
    thread_class = None
    cfg = context.cfg
    if not cfg.get('cola.inotify', default=True):
        msg = N_(
            'File system change monitoring: disabled because'
            ' "cola.inotify" is false.\n'
        )
        Interaction.log(msg)
    elif AVAILABLE == 'inotify':
        thread_class = _InotifyThread
    elif AVAILABLE == 'pywin32':
        thread_class = _Win32Thread
    else:
        if utils.is_win32():
            msg = N_(
                'File system change monitoring: disabled because pywin32'
                ' is not installed.\n'
            )
            Interaction.log(msg)
        elif utils.is_linux():
            msg = N_(
                'File system change monitoring: disabled because libc'
                ' does not support the inotify system calls.\n'
            )
            Interaction.log(msg)
    return _Monitor(context, thread_class)