-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathgit-nautilus-icons.py
805 lines (719 loc) · 33 KB
/
git-nautilus-icons.py
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
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
#####################################################################
# #
# Copyright 2016, Chris Billington #
# #
# This file is part of the git-nautilus-icons project (see #
# https://github.com/chrisjbillington/git_nautilus_icons) and is #
# licensed under the Simplified BSD License. See LICENSE in #
# the root directory of the project for the full license. #
# #
#####################################################################
from __future__ import print_function, unicode_literals
import sys
import os
import pathlib
from enum import IntEnum, unique
from subprocess import Popen, PIPE, CalledProcessError, check_call
from collections import defaultdict
import socket
import threading
import tempfile
from binascii import hexlify
try:
from multiprocessing.connection import Connection
except ImportError:
from _multiprocessing import Connection
import gi
from gi.repository import GObject
PY2 = sys.version_info.major == 2
# A string in sys.argv so that the worker process can identify itself:
WORKER_ARG = 'git-nautilus-icons-worker'
if WORKER_ARG not in sys.argv:
# Only import the extension modules if we are not the worker process
if sys.argv[0] == 'nemo':
gi.require_version('Nemo', '3.0')
from gi.repository import Nemo as Nautilus
elif sys.argv[0] == 'caja':
gi.require_version('Caja', '2.0')
from gi.repository import Caja as Nautilus
else:
try:
gi.require_version('Nautilus', '3.0')
except ValueError:
gi.require_version('Nautilus', '4.0')
from gi.repository import Nautilus
BLACKLIST_TEMPLATE = """# git-{nautilus,nemo,caja}-icons blacklist file.
#
# The extension will ignore files in any directories listed in this file, and any of
# their subdirectories.
#
# Blank lines and lines beginning with '#' will be ignored. A '#' character within a
# line will be treated as part of the directory path, so end-of-line comments are not
# allowed.
# Example:
/home/chrisjbillington/clones/example_repo.git
# Another example:
/home/chrisjbillington/clones/some_other_example_huge_repo.git
"""
# Make blacklist file if it doesn't exist:
_conf = os.getenv('XDG_CONFIG_HOME', os.path.join(os.getenv('HOME'), '.config'))
BLACKLIST_FILE = os.path.join(_conf, 'git-nautilus-icons', 'blacklist.conf')
if not os.path.exists(BLACKLIST_FILE):
# Backcompat for before the rename:
OLD_BLACKLIST_FILE = os.path.join(_conf, 'git_nautilus_icons', 'blacklist.conf')
if os.path.exists(OLD_BLACKLIST_FILE):
BLACKLIST_FILE = OLD_BLACKLIST_FILE
if not os.path.exists(BLACKLIST_FILE):
check_call(['mkdir', '-p', os.path.dirname(BLACKLIST_FILE)])
with open(BLACKLIST_FILE, 'w') as f:
f.write(BLACKLIST_TEMPLATE)
blacklist = []
with open(BLACKLIST_FILE) as f:
for line in f.readlines():
line = line.strip()
if line and not line.startswith('#'):
if not line.endswith('/'):
line += '/'
blacklist.append(line)
DEBUG = False
def blacklisted(path):
path += '/'
if any(path.startswith(s + '/') for s in blacklist):
if DEBUG:
print('path is blacklisted:', path)
return True
return False
# Constants to represent requests and information between parent and child. Not an enum
# because they don't survive pickling between processes in the way we're doing it.
SEND_READY = 0
STILL_WORKING = 1
ALL_DONE = 2
ACK = 4
# For printing the above:
STATUS = {
SEND_READY: 'SEND_READY',
STILL_WORKING: 'STILL_WORKING',
ALL_DONE: 'ALL_DONE',
ACK: 'ACK',
}
@unique
class SyncStatus(IntEnum):
"""Possible statuses for a repository's sync state."""
ERROR = -1
NOT_AHEAD = 0
AHEAD = 1
@unique
class RepoStatus(IntEnum):
"""Whether a folder is a git repo"""
ERROR = -1
NOT_A_REPO = 0
IS_A_REPO = 1
@unique
class IndexStatus(IntEnum):
"""Possible statuses for a file in the index, ordered by severity. Severiy
also consistent with being sorted against WorktreeStatus"""
ERROR = -1
NOT_IN_INDEX = 0
CLEAN = 1
ADDED = 3
RENAMED = 4
DELETED = 5
MODIFIED = 6
@unique
class WorktreeStatus(IntEnum):
"""Possible statuses for a file in the work tree, ordered by severity
Severiy also consistent with being sorted against IndexStatus"""
ERROR = -1
IGNORED = 0
CLEAN = 1
UNTRACKED = 2
DELETED = 5
MODIFIED = 6
UNMERGED = 7
IS_DOT_GIT = 8
@unique
class MergeStatus(IntEnum):
"""Possible statuses for an unmerged file, ordered by severity"""
ERROR = -1
NO_CONFLICT = 0
THEY_DELETED = 1
WE_DELETED = 2
BOTH_ADDED = 3
BOTH_MODIFIED = 4
STATUS_CODES = {
# Possible statuses returned by 'git status -z':
' M': (IndexStatus.CLEAN, WorktreeStatus.MODIFIED, MergeStatus.NO_CONFLICT),
'MM': (IndexStatus.MODIFIED, WorktreeStatus.MODIFIED, MergeStatus.NO_CONFLICT),
'AM': (IndexStatus.ADDED, WorktreeStatus.MODIFIED, MergeStatus.NO_CONFLICT),
'RM': (IndexStatus.RENAMED, WorktreeStatus.MODIFIED, MergeStatus.NO_CONFLICT),
'M ': (IndexStatus.MODIFIED, WorktreeStatus.CLEAN, MergeStatus.NO_CONFLICT),
'R ': (IndexStatus.RENAMED, WorktreeStatus.CLEAN, MergeStatus.NO_CONFLICT),
'A ': (IndexStatus.ADDED, WorktreeStatus.CLEAN, MergeStatus.NO_CONFLICT),
'D ': (IndexStatus.DELETED, WorktreeStatus.CLEAN, MergeStatus.NO_CONFLICT),
' D': (IndexStatus.CLEAN, WorktreeStatus.DELETED, MergeStatus.NO_CONFLICT),
'MD': (IndexStatus.MODIFIED, WorktreeStatus.DELETED, MergeStatus.NO_CONFLICT),
'AD': (IndexStatus.ADDED, WorktreeStatus.DELETED, MergeStatus.NO_CONFLICT),
'RD': (IndexStatus.RENAMED, WorktreeStatus.DELETED, MergeStatus.NO_CONFLICT),
'UD': (IndexStatus.CLEAN, WorktreeStatus.UNMERGED, MergeStatus.THEY_DELETED),
'DU': (IndexStatus.CLEAN, WorktreeStatus.UNMERGED, MergeStatus.WE_DELETED),
'AA': (IndexStatus.CLEAN, WorktreeStatus.UNMERGED, MergeStatus.BOTH_ADDED),
'UU': (IndexStatus.CLEAN, WorktreeStatus.UNMERGED, MergeStatus.BOTH_MODIFIED),
'??': (IndexStatus.NOT_IN_INDEX, WorktreeStatus.UNTRACKED, MergeStatus.NO_CONFLICT),
'!!': (IndexStatus.NOT_IN_INDEX, WorktreeStatus.IGNORED, MergeStatus.NO_CONFLICT),
# Some extra ones I'm adding for convenience:
# When a tracked file listed in 'git ls-tree' is not present
# in the output of 'git status -z', then we assume it is
# unmodified and use this status:
'CLEAN': (IndexStatus.CLEAN, WorktreeStatus.CLEAN, MergeStatus.NO_CONFLICT),
# When a file appears twice in 'git status -z', once with 'D '
# and once with '??', because it is staged for deletion but
# nontheless present in the worktree, we use this status:
'D?': (IndexStatus.DELETED, WorktreeStatus.UNTRACKED, MergeStatus.NO_CONFLICT),
# When my code doesn't know what do do because it finds a file
# git doesn't tell it about, or because a file appears twice
# but not in the manner handled above, then we use this
# status:
'ERROR': (IndexStatus.ERROR, WorktreeStatus.ERROR, MergeStatus.ERROR),
'IS_DOT_GIT': (IndexStatus.CLEAN, WorktreeStatus.IS_DOT_GIT, MergeStatus.NO_CONFLICT)}
# The status of the files in the 'icon_testing_dir' directory, hard coded to
# demonstrate what different statuses look like:
EXAMPLE_FILE_STATUSES = {'01 clean repo': (SyncStatus.NOT_AHEAD, RepoStatus.IS_A_REPO) + STATUS_CODES['CLEAN'],
'02 clean repo ahead of remote':
(SyncStatus.AHEAD, RepoStatus.IS_A_REPO) + STATUS_CODES['CLEAN'],
'03 ignored': STATUS_CODES['!!'],
'04 clean': STATUS_CODES['CLEAN'],
'05 untracked': STATUS_CODES['??'],
'06 unstaged changes': STATUS_CODES[' M'],
'07 unstaged deletion': STATUS_CODES[' D'],
'08 staged changes': STATUS_CODES['M '],
'09 staged rename': STATUS_CODES['R '],
'10 staged new file': STATUS_CODES['A '],
'11 staged deletion': STATUS_CODES['D '],
'12 staged deletion, restored in work tree': STATUS_CODES['D?'],
'13 staged and unstaged changes': STATUS_CODES['MM'],
'14 staged rename, unstaged changes': STATUS_CODES['RM'],
'15 staged new file, unstaged changes': STATUS_CODES['AM'],
'16 staged changes, unstaged deletion': STATUS_CODES['MD'],
'17 staged rename, unstaged deletion': STATUS_CODES['RD'],
'18 staged new file, unstaged deletion': STATUS_CODES['AD'],
'19 unmerged, both added': STATUS_CODES['AA'],
'20 unmerged, both modified': STATUS_CODES['UU'],
'21 unmerged, changed by them, deleted by us': STATUS_CODES['DU'],
'22 unmerged, changed by us, deleted by them': STATUS_CODES['UD'],
'23 repo with clean index and untracked files':
(SyncStatus.NOT_AHEAD, RepoStatus.IS_A_REPO,
IndexStatus.CLEAN, WorktreeStatus.UNTRACKED, MergeStatus.NO_CONFLICT),
'24 repo with staged and unstaged deletions':
(SyncStatus.NOT_AHEAD, RepoStatus.IS_A_REPO,
IndexStatus.DELETED, WorktreeStatus.DELETED, MergeStatus.NO_CONFLICT),
}
ICON_TESTING_DIR = 'git_nautilus_icons/icon_testing_dir'
def example_statuses(path):
return {os.path.join(path, name): value for name, value in EXAMPLE_FILE_STATUSES.items()}
def get_icon(status):
if len(status) == 3:
# It's a file
index_status, worktree_status, merge_status = status
if worktree_status == WorktreeStatus.IS_DOT_GIT:
return 'git-dotgit'
sync_status = SyncStatus.NOT_AHEAD
repo_status = RepoStatus.NOT_A_REPO
elif len(status) == 5:
# It's a repo
sync_status, repo_status, index_status, worktree_status, merge_status = status
else:
sys.stderr.write("invalid length of status tuple {}\n".format(len(status)))
sub_icons = []
if sync_status is SyncStatus.AHEAD:
sub_icons.append('ahead')
if repo_status is RepoStatus.IS_A_REPO:
sub_icons.append('repo')
if worktree_status is WorktreeStatus.UNMERGED:
if merge_status is MergeStatus.THEY_DELETED:
sub_icons.append('unmerged-modified')
sub_icons.append('unmerged-deleted')
elif merge_status is MergeStatus.WE_DELETED:
sub_icons.append('unmerged-deleted')
sub_icons.append('unmerged-modified')
elif merge_status is MergeStatus.BOTH_ADDED:
sub_icons.append('unmerged-added')
sub_icons.append('unmerged-added')
elif merge_status is MergeStatus.BOTH_MODIFIED:
sub_icons.append('unmerged-modified')
sub_icons.append('unmerged-modified')
# We only show index clean if work tree is untracked (only applies for directories/repos):
else:
if index_status is IndexStatus.CLEAN and worktree_status is WorktreeStatus.UNTRACKED:
sub_icons.append('clean')
elif index_status is IndexStatus.ADDED:
sub_icons.append('added')
elif index_status is IndexStatus.RENAMED:
sub_icons.append('renamed')
elif index_status is IndexStatus.DELETED:
sub_icons.append('deleted')
elif index_status is IndexStatus.MODIFIED:
sub_icons.append('modified')
if worktree_status is WorktreeStatus.CLEAN:
sub_icons.append('clean')
elif worktree_status is WorktreeStatus.UNTRACKED:
sub_icons.append('untracked')
elif worktree_status is WorktreeStatus.DELETED:
sub_icons.append('deleted')
elif worktree_status is WorktreeStatus.MODIFIED:
sub_icons.append('modified')
if not sub_icons:
return None
return 'git-' + '-'.join(sub_icons)
class NotARepo(CalledProcessError):
pass
class FileStatuses(dict):
"""Dictionary like object which can lookup the status of a file even if
only a an ancestor directory is listed as having that status, and not the
file specifically. This is because 'git status' abbreviates its output in
this way, and telling git to give full output could send it into massive
directories that neither it nor Nautilus are interested in."""
def __init__(self, repo_root):
self.repo_root = repo_root
dict.__init__(self)
def get_status(self, path):
try:
return dict.__getitem__(self, path)
except KeyError:
# Try parent_directories:
paths_tried = []
i = 0
# Really make sure we don't infinitely loop here if I've made a mistake
while i < 1000:
i += 1
if path in ('/', self.repo_root):
return STATUS_CODES['ERROR']
path += '/'
try:
result = dict.__getitem__(self, path)
except KeyError:
paths_tried.append(path)
path = os.path.dirname(os.path.normpath(path))
else:
# Add all the directories we tried already, so that future
# lookups from the same starting directory will find their
# result sooner:
for path in paths_tried:
self[path] = result
return result
sys.stderr.write("Looping too long in FileStatuses.__getitem__\n")
return STATUS_CODES['ERROR']
def git_call(cmd, path):
"""Calls a command with check_output, raising NotARepo if there is no git
repo there. This lets us avoid the race condition of a repo disappearing
disappear before we call the command."""
try:
proc = Popen(cmd, cwd=path, stdout=PIPE, stderr=PIPE)
stdout, stderr = proc.communicate()
except OSError:
# Git not installed, or repo path doesn't exist or isn't a directory.
raise NotARepo(1, cmd, "Couldn't run git command - path might not exist")
if proc.returncode:
# Something went wrong - repo got deleted while we were reading it, or something
# like that.
raise NotARepo(proc.returncode, cmd, output=(stdout + stderr))
return stdout.decode('utf8')
def is_git_repo(path):
"""returns whether a path is a git repo"""
if blacklisted(path):
return False
return os.path.isdir(os.path.join(path, '.git'))
def is_in_work_tree(path):
"""returns whether a path is in the work tree of a git repo (ie, not
inside .git!)"""
if blacklisted(path):
return False
cmd = ['git', 'rev-parse', '--is-inside-work-tree']
try:
return git_call(cmd, path).strip() == 'true'
except NotARepo:
return False
def get_repo_root(path):
"""Returns the root directory of a repo, given a directory within it,
or raises NotARepo if the directory is not in a git repo"""
if blacklisted(path):
raise NotARepo
cmd = ['git', 'rev-parse', '--show-cdup']
output = git_call(cmd, path).strip()
return os.path.normpath(os.path.join(path, output))
def repo_is_ahead(path):
"""Returns whether the repo at a given path has any unpushed commits"""
cmd = ['git', 'for-each-ref', '--format="%(push:track)"', 'refs/heads']
return 'ahead' in git_call(cmd, path)
def get_folder_overall_status(path, file_statuses, all_statuses):
"""Returns a 3-tuple of an IndexStatus, WorktreeStatus and MergeStatus,
chosen based on the most severe of the corresponding statuses of the
files. file_statuses should be a set of status tuples for individual
files."""
if file_statuses:
index_statuses, worktree_statuses, merge_statuses = zip(*file_statuses)
index_status = max(index_statuses)
worktree_status = max(worktree_statuses)
merge_status = max(merge_statuses)
else:
# No files listed. Maybe the directory, or a parent directory, is listed:
index_status, worktree_status, merge_status = all_statuses.get_status(path)
return index_status, worktree_status, merge_status
def get_repo_overall_status(path, statuses):
"""Return the repo's overall status, 5-tuple of a SyncStatus, RepoStatus,
IndexStatus, WorktreeStatus and MergeStatus. The latter three are chosen
based on the most severe of the corresponding statuses of the files"""
if repo_is_ahead(path):
sync_status = SyncStatus.AHEAD
else:
sync_status = SyncStatus.NOT_AHEAD
repo_status = RepoStatus.IS_A_REPO
if not statuses:
# No files! Therefore clean.
index_status = IndexStatus.CLEAN
worktree_status = WorktreeStatus.CLEAN
merge_status = MergeStatus.NO_CONFLICT
else:
index_status, worktree_status, merge_status = get_folder_overall_status(
path, set(statuses.values()), statuses
)
return sync_status, repo_status, index_status, worktree_status, merge_status
def function_with_cache(orig_func):
"""Cache results of a function clear cache with func.cache.clear. Does not support
kwargs."""
def f(*args):
try:
return f.cache[args]
except KeyError:
f.cache[(args)] = orig_func(*args)
return f.cache[args]
f.cache = {}
return f
@function_with_cache
def repo_status(path):
if DEBUG:
print("repo status:", path)
"""Return the status of the repo overall as well as a dict of the statuses
of all non-ignored files in it. All files within the work tree but not
listed in the output have the status of their parent directories.
Raises NotARepo if the path no longer points to a git repo."""
repo_root = get_repo_root(path)
# 'git status' will get all files other than unmodified ones:
status_command = ['git', 'status', '-z']
status_output = git_call(status_command, path)
statuses = FileStatuses(repo_root)
status_entries = status_output.split('\x00')[:-1]
i = 0
while i < len(status_entries):
status_entry = status_entries[i]
status = status_entry[0:2]
# Consider a change in file type (link to non-link or vice-versa) a
# modification:
status = status.replace('T', 'M')
# Consider a copy into a new file to be an addition:
status = status.replace('C', 'A')
relpath = status_entry[3:]
filename = os.path.join(repo_root, relpath)
if filename in statuses:
# Same file can be listed twice if for example there is a staged
# deletion and then the file is re-added:
if (statuses[filename] == STATUS_CODES['D '] and status == '??'):
status_tuple = STATUS_CODES['D?']
else:
sys.stderr.write("Do not know how to interpret file present twice in 'git status -z' " +
"with statuses '{}' and '{}'\n".format(statuses[filename], status))
status_tuple = STATUS_CODES['ERROR']
else:
status_tuple = STATUS_CODES[status]
statuses[filename] = status_tuple
if status[0] == 'R':
# A rename, the next entry is the original filename. Skip it.
i += 1
i += 1
# And now to get all the unmodified files:
lstree_command = ['git', 'ls-tree', '--full-tree', '-zr', '--name-only', 'HEAD']
try:
lstree_output = git_call(lstree_command, path)
except CalledProcessError as e:
if not 'Not a valid object name HEAD' in e.output.decode('utf8'):
# Ignore if there is no HEAD (no commits probably). Otherwise raise.
raise
else:
lstree_entries = set(lstree_output.split('\x00')[:-1])
for lstree_relpath in lstree_entries:
filename = os.path.join(repo_root, lstree_relpath)
if filename not in statuses:
statuses[filename] = STATUS_CODES['CLEAN']
overall_status = get_repo_overall_status(path, statuses)
return overall_status, statuses
def get_statuses_by_dir(path, file_statuses):
"""Sort the file statuses into which directory at the current level they
are under. Only keep unique statuses, and return a dictionary of sets for
each directory rooted at the given path."""
statuses_by_dir = defaultdict(set)
prefix = path + '/'
len_prefix = len(prefix)
for name, status in file_statuses.items():
if not name.startswith(prefix):
continue
dirname = name[:name.find('/', len_prefix)]
statuses_by_dir[dirname].add(status)
return statuses_by_dir
@function_with_cache
def directory_status(path):
if DEBUG:
print("directory_status:", path)
"""Returns the statuses for all the files/directories in a given path
(without recursing). For folders in a repo, their status is given as the
most severe of their contents. For repositories, their status is given as
their overall status, which is the same as for a folder but also includes
whether the repo has unpushed commits or not, as well as the fact that it
is a repo. For submodules, their overall status is given, but is
calculated as if the repo contained a file with the status of the
submodule itself. Thus, if a submodule is itself clean, but is checked out
at a different commit than recorded by a commit in the parent repo, then
it will appear as modified."""
if path.endswith(ICON_TESTING_DIR):
return example_statuses(path)
statuses = {}
if not is_in_work_tree(path):
# Not in a git repo. Give statuses of any git repos within:
try:
subdirs = os.listdir(path)
except (OSError,FileNotFoundError):
# Deleted, unmounted, or otherwise gone
subdirs = []
for basename in subdirs:
fullname = os.path.join(path, basename)
if os.path.isdir(fullname) and is_git_repo(fullname):
try:
status, _ = repo_status(fullname)
except NotARepo:
# Repo deleted
continue
else:
status = None
statuses[fullname] = status
return statuses
else:
try:
_, file_statuses = repo_status(path)
except NotARepo:
# Repo deleted
return {}
# As an optimisation, collect the set of statuses in each directory at
# the current level we're at:
statuses_by_dir = get_statuses_by_dir(path, file_statuses)
try:
subdirs = os.listdir(path)
except (OSError,FileNotFoundError):
# Deleted, unmounted, or otherwise gone
subdirs = []
for basename in subdirs:
fullname = os.path.join(path, basename)
if basename == '.git':
status = STATUS_CODES['IS_DOT_GIT']
elif not os.path.isdir(fullname):
# A normal file:
status = file_statuses.get_status(fullname)
elif is_git_repo(fullname):
# A submodule. Give its overall status, calculated as if it
# contained a file with its own status in the parent repo.
# This ensures the most severe of the subrepo's own status and
# its status in the parent repo will be shown.
file_status = file_statuses.get_status(fullname)
try:
_, subrepo_file_statuses = repo_status(fullname)
subrepo_file_statuses.update({fullname: file_status})
status = get_repo_overall_status(fullname, subrepo_file_statuses)
except NotARepo:
# subrepo deleted
continue
else:
# A normal folder. Give its overall
status = get_folder_overall_status(
fullname, statuses_by_dir[fullname], file_statuses
)
statuses[fullname] = status
return statuses
def get_filepath(file):
"""Extract filepath from the URI in a NautilusVFSFile object. Return the
filepath or None if uri scheme is not 'file'"""
if sys.version_info.major == 2:
from urlparse import urlparse
from urllib import unquote
else:
from urllib.parse import urlparse
from urllib.parse import unquote
def _checkdecode(s):
return s.decode('utf8') if isinstance(s, bytes) else s
parsed_uri = urlparse(file.get_uri())
if parsed_uri.scheme == 'file':
netloc = _checkdecode(parsed_uri.netloc)
path = _checkdecode(unquote(parsed_uri.path))
return os.path.abspath(os.path.join(netloc, path))
class WorkerProcess(object):
TIMEOUT = 0.01
"""A separate process for making git status calls without blocking Nautilis's GUI.
This could have been a thread instead of a process, but nautilus-python has an issue
where it does not realease the GIL when it has finished running extension code, so
the interpreter cannot keep running background threads. So no problem, we use a
separate process instead."""
def __init__(self, conn):
self.conn = conn
# Files whose status we still need to check
self.pending = set()
# Files whose status we're waiting to send to the parent process
self.ready = set()
self.lock = threading.Lock()
self.processing_required = threading.Event()
self.git_status_loop_thread = threading.Thread(target=self.git_status_loop)
self.git_status_loop_thread.daemon = True
self.git_status_loop_thread.start()
def git_status_loop(self):
"""Runs in a thread to get git statuses for files in self.pending, and add them
to self.ready. Does work until self.pending is empty, and then blocks until
self.processing_required is set."""
while True:
self.processing_required.wait()
if DEBUG:
print("worker: git status loop: triggered")
self.processing_required.clear()
while self.pending:
# We process in a chunk so that we can cache git status calls and
# directory status calls within a chunk, but that new files arriving in
# the meantime will not use the cache, as it might be invalid by then.
repo_status.cache.clear()
directory_status.cache.clear()
pending = self.pending.copy()
while pending:
path = pending.pop()
status = directory_status(os.path.dirname(path)).get(path, None)
if status is not None:
icon = get_icon(status)
if icon is not None:
with self.lock:
if DEBUG:
print('adding to ready set:', path)
self.ready.add((path, icon))
self.pending.remove(path)
def run(self):
timeout = None
while True:
# Block until we get a message. If we get a message with a filepath, set
# timeout = self.TIMEOUT so that we can detect when files stop coming. This
# way we can batch our processing. Once messages cease, set timeout = None
# to block again.
if self.conn.poll(timeout):
try:
message = self.conn.recv()
except EOFError:
return
if message == SEND_READY:
# Send the parent the details of the filepaths we've processed so
# far:
with self.lock:
if DEBUG:
print('worker sending %d processed files' % len(self.ready))
if self.pending:
status = STILL_WORKING
else:
status = ALL_DONE
self.conn.send((self.ready, status))
self.ready = set()
else:
# It's a filepath to be processed, add it to the pile:
with self.lock:
self.pending.add(message)
self.conn.send(ACK)
timeout = self.TIMEOUT
else:
# Timed out. Trigger processing to start and block until the next
# message
self.processing_required.set()
timeout = None
def start_worker_process():
"""Called in the parent process to set up the worker. This is not done with the
Python multiprocessing module because a subprocess made via forking will not work in
the context of the extension with Nautilus running, and we want to retain Python 2
compatibility for now so can't use the 'spawn' option for a fresh process. So we
start a process and set up a connection with it somewhat manually"""
sock_addr = os.path.join(
tempfile.gettempdir(), 'git-nautilus-icons-%s' % hexlify(os.urandom(8)).decode()
)
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(sock_addr)
sock.listen(1)
child = Popen([sys.executable, __file__, WORKER_ARG, sock_addr])
client, _ = sock.accept()
os.unlink(sock_addr)
sock.close()
conn = Connection(os.dup(client.fileno()) if PY2 else client.detach())
assert conn.recv() == 'hello'
conn.send('hello')
return conn, child
def setup_connection_with_parent():
"""Called in the child process to connect to the parent process"""
sock_addr = sys.argv[1]
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_addr)
conn = Connection(os.dup(sock.fileno()) if PY2 else sock.detach())
conn.send('hello')
assert conn.recv() == 'hello'
return conn
if WORKER_ARG not in sys.argv:
# Only define the extension info provider in the parent class
class GitNautilusIcons(GObject.GObject, Nautilus.InfoProvider):
INTERVAL = 50
def __init__(self, *args, **kwargs):
super(GitNautilusIcons, self).__init__(*args, **kwargs)
self.timeout_id = None
self.conn, self.child = start_worker_process()
def invalidate_directory(self, directory):
"""Invalidate Nautilus's file info for all files in the given directory,
triggering it to ask us for them again"""
for path in os.listdir(directory):
fullpath = os.path.join(directory, path)
if sys.version_info.major == 2:
fullpath = fullpath.encode('utf8')
uri = pathlib.Path(fullpath).as_uri()
fileinfo = Nautilus.FileInfo.create_for_uri(uri)
fileinfo.invalidate_extension_info()
def update_file_info(self, file):
filepath = get_filepath(file)
if filepath is not None:
# Put it in the pipe for the subprocess to deal with, and ensure the
# timeout is running to check when the subprocess is done:
self.conn.send(filepath)
assert self.conn.recv() == ACK
if self.timeout_id is None:
self.timeout_id = GObject.timeout_add(self.INTERVAL, self.timeout)
def timeout(self):
if DEBUG:
print("parent: timeout")
self.conn.send(SEND_READY)
# print("parent: SEND_READY sent, waiting for response")
files, worker_status = self.conn.recv()
if DEBUG:
print("parent: got response:", STATUS[worker_status])
for filepath, icon in files:
if DEBUG:
print("adding icon for file:", filepath)
self.set_icon(filepath, icon)
if worker_status == ALL_DONE:
GObject.source_remove(self.timeout_id)
self.timeout_id = None
return False
elif worker_status == STILL_WORKING:
return True
else:
raise ValueError(worker_status)
def set_icon(self, filepath, icon):
uri = pathlib.Path(filepath).as_uri()
file = Nautilus.FileInfo.create_for_uri(uri)
file.add_emblem(icon)
else:
# We are in the worker process. Start the worker.
sys.argv.remove(WORKER_ARG)
conn = setup_connection_with_parent()
worker = WorkerProcess(conn)
worker.run()