1from __future__ import division, absolute_import, unicode_literals
2from binascii import unhexlify
3import copy
4import fnmatch
5import os
6from os.path import join
7import re
8import struct
9
10from . import core
11from . import observable
12from . import utils
13from . import version
14from .compat import int_types
15from .git import STDOUT
16from .compat import ustr
17
18BUILTIN_READER = os.environ.get('GIT_COLA_BUILTIN_CONFIG_READER', False)
19
20_USER_CONFIG = core.expanduser(join('~', '.gitconfig'))
21_USER_XDG_CONFIG = core.expanduser(
22    join(core.getenv('XDG_CONFIG_HOME', join('~', '.config')), 'git', 'config')
23)
24
25
26def create(context):
27    """Create GitConfig instances"""
28    return GitConfig(context)
29
30
31def _stat_info(git):
32    # Try /etc/gitconfig as a fallback for the system config
33    paths = [
34        ('system', '/etc/gitconfig'),
35        ('user', _USER_XDG_CONFIG),
36        ('user', _USER_CONFIG),
37    ]
38    config = git.git_path('config')
39    if config:
40        paths.append(('repo', config))
41
42    statinfo = []
43    for category, path in paths:
44        try:
45            statinfo.append((category, path, core.stat(path).st_mtime))
46        except OSError:
47            continue
48    return statinfo
49
50
51def _cache_key(git):
52    # Try /etc/gitconfig as a fallback for the system config
53    paths = [
54        '/etc/gitconfig',
55        _USER_XDG_CONFIG,
56        _USER_CONFIG,
57    ]
58    config = git.git_path('config')
59    if config:
60        paths.append(config)
61
62    mtimes = []
63    for path in paths:
64        try:
65            mtimes.append(core.stat(path).st_mtime)
66        except OSError:
67            continue
68    return mtimes
69
70
71def _config_to_python(v):
72    """Convert a Git config string into a Python value"""
73
74    if v in ('true', 'yes'):
75        v = True
76    elif v in ('false', 'no'):
77        v = False
78    else:
79        try:
80            v = int(v)
81        except ValueError:
82            pass
83    return v
84
85
86def unhex(value):
87    """Convert a value (int or hex string) into bytes"""
88    if isinstance(value, int_types):
89        # If the value is an integer then it's a value that was converted
90        # by the config reader.  Zero-pad it into a 6-digit hex number.
91        value = '%06d' % value
92    return unhexlify(core.encode(value.lstrip('#')))
93
94
95def _config_key_value(line, splitchar):
96    """Split a config line into a (key, value) pair"""
97
98    try:
99        k, v = line.split(splitchar, 1)
100    except ValueError:
101        # the user has an empty entry in their git config,
102        # which Git interprets as meaning "true"
103        k = line
104        v = 'true'
105    return k, _config_to_python(v)
106
107
108class GitConfig(observable.Observable):
109    """Encapsulate access to git-config values."""
110
111    message_user_config_changed = 'user_config_changed'
112    message_repo_config_changed = 'repo_config_changed'
113    message_updated = 'updated'
114
115    def __init__(self, context):
116        observable.Observable.__init__(self)
117        self.git = context.git
118        self._map = {}
119        self._system = {}
120        self._user = {}
121        self._user_or_system = {}
122        self._repo = {}
123        self._all = {}
124        self._cache_key = None
125        self._configs = []
126        self._config_files = {}
127        self._attr_cache = {}
128        self._find_config_files()
129
130    def reset(self):
131        self._cache_key = None
132        self._configs = []
133        self._config_files.clear()
134        self._attr_cache = {}
135        self._find_config_files()
136        self.reset_values()
137
138    def reset_values(self):
139        self._map.clear()
140        self._system.clear()
141        self._user.clear()
142        self._user_or_system.clear()
143        self._repo.clear()
144        self._all.clear()
145
146    def user(self):
147        return copy.deepcopy(self._user)
148
149    def repo(self):
150        return copy.deepcopy(self._repo)
151
152    def all(self):
153        return copy.deepcopy(self._all)
154
155    def _find_config_files(self):
156        """
157        Classify git config files into 'system', 'user', and 'repo'.
158
159        Populates self._configs with a list of the files in
160        reverse-precedence order.  self._config_files is populated with
161        {category: path} where category is one of 'system', 'user', or 'repo'.
162
163        """
164        # Try the git config in git's installation prefix
165        statinfo = _stat_info(self.git)
166        self._configs = [x[1] for x in statinfo]
167        self._config_files = {}
168        for (cat, path, _) in statinfo:
169            self._config_files[cat] = path
170
171    def _cached(self):
172        """
173        Return True when the cache matches.
174
175        Updates the cache and returns False when the cache does not match.
176
177        """
178        cache_key = _cache_key(self.git)
179        if self._cache_key is None or cache_key != self._cache_key:
180            self._cache_key = cache_key
181            return False
182        return True
183
184    def update(self):
185        """Read git config value into the system, user and repo dicts."""
186        if self._cached():
187            return
188
189        self.reset_values()
190
191        if 'system' in self._config_files:
192            self._system.update(self.read_config(self._config_files['system']))
193
194        if 'user' in self._config_files:
195            self._user.update(self.read_config(self._config_files['user']))
196
197        if 'repo' in self._config_files:
198            self._repo.update(self.read_config(self._config_files['repo']))
199
200        for dct in (self._system, self._user):
201            self._user_or_system.update(dct)
202
203        for dct in (self._system, self._user, self._repo):
204            self._all.update(dct)
205
206        self.notify_observers(self.message_updated)
207
208    def read_config(self, path):
209        """Return git config data from a path as a dictionary."""
210
211        if BUILTIN_READER:
212            return self._read_config_file(path)
213
214        dest = {}
215        if version.check_git(self, 'config-includes'):
216            args = ('--null', '--file', path, '--list', '--includes')
217        else:
218            args = ('--null', '--file', path, '--list')
219        config_lines = self.git.config(*args)[STDOUT].split('\0')
220        for line in config_lines:
221            if not line:
222                # the user has an invalid entry in their git config
223                continue
224            k, v = _config_key_value(line, '\n')
225            self._map[k.lower()] = k
226            dest[k] = v
227        return dest
228
229    def _read_config_file(self, path):
230        """Read a .gitconfig file into a dict"""
231
232        config = {}
233        header_simple = re.compile(r'^\[(\s+)]$')
234        header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$')
235
236        with core.xopen(path, 'rt') as f:
237            file_lines = f.readlines()
238
239        stripped_lines = [line.strip() for line in file_lines]
240        lines = [line for line in stripped_lines if bool(line)]
241        prefix = ''
242        for line in lines:
243            if line.startswith('#'):
244                continue
245
246            match = header_simple.match(line)
247            if match:
248                prefix = match.group(1) + '.'
249                continue
250            match = header_subkey.match(line)
251            if match:
252                prefix = match.group(1) + '.' + match.group(2) + '.'
253                continue
254
255            k, v = _config_key_value(line, '=')
256            k = prefix + k
257            self._map[k.lower()] = k
258            config[k] = v
259
260        return config
261
262    def _get(self, src, key, default, fn=None, cached=True):
263        if not cached or not src:
264            self.update()
265        try:
266            value = self._get_with_fallback(src, key)
267        except KeyError:
268            if fn:
269                value = fn()
270            else:
271                value = default
272        return value
273
274    def _get_with_fallback(self, src, key):
275        try:
276            return src[key]
277        except KeyError:
278            pass
279        key = self._map.get(key.lower(), key)
280        try:
281            return src[key]
282        except KeyError:
283            pass
284        # Allow the final KeyError to bubble up
285        return src[key.lower()]
286
287    def get(self, key, default=None, fn=None, cached=True):
288        """Return the string value for a config key."""
289        return self._get(self._all, key, default, fn=fn, cached=cached)
290
291    def get_all(self, key):
292        """Return all values for a key sorted in priority order
293
294        The purpose of this function is to group the values returned by
295        `git config --show-origin --get-all` so that the relative order is
296        preserved but can still be overridden at each level.
297
298        One use case is the `cola.icontheme` variable, which is an ordered
299        list of icon themes to load.  This value can be set both in
300        ~/.gitconfig as well as .git/config, and we want to allow a
301        relative order to be defined in either file.
302
303        The problem is that git will read the system /etc/gitconfig,
304        global ~/.gitconfig, and then the local .git/config settings
305        and return them in that order, so we must post-process them to
306        get them in an order which makes sense for use for our values.
307        Otherwise, we cannot replace the order, or make a specific theme used
308        first, in our local .git/config since the native order returned by
309        git will always list the global config before the local one.
310
311        get_all() allows for this use case by gathering all of the per-config
312        values separately and then orders them according to the expected
313        local > global > system order.
314
315        """
316        result = []
317        status, out, _ = self.git.config(key, z=True, get_all=True, show_origin=True)
318        if status == 0:
319            current_source = ''
320            current_result = []
321            partial_results = []
322            items = [x for x in out.rstrip(chr(0)).split(chr(0)) if x]
323            for i in range(len(items) // 2):
324                source = items[i * 2]
325                value = items[i * 2 + 1]
326                if source != current_source:
327                    current_source = source
328                    current_result = []
329                    partial_results.append(current_result)
330                current_result.append(value)
331            # Git's results are ordered System, Global, Local.
332            # Reverse the order here so that Local has the highest priority.
333            for partial_result in reversed(partial_results):
334                result.extend(partial_result)
335
336        return result
337
338    def get_user(self, key, default=None):
339        return self._get(self._user, key, default)
340
341    def get_repo(self, key, default=None):
342        return self._get(self._repo, key, default)
343
344    def get_user_or_system(self, key, default=None):
345        return self._get(self._user_or_system, key, default)
346
347    def set_user(self, key, value):
348        if value in (None, ''):
349            self.git.config('--global', key, unset=True)
350        else:
351            self.git.config('--global', key, python_to_git(value))
352        self.update()
353        msg = self.message_user_config_changed
354        self.notify_observers(msg, key, value)
355
356    def set_repo(self, key, value):
357        if value in (None, ''):
358            self.git.config(key, unset=True)
359        else:
360            self.git.config(key, python_to_git(value))
361        self.update()
362        msg = self.message_repo_config_changed
363        self.notify_observers(msg, key, value)
364
365    def find(self, pat):
366        pat = pat.lower()
367        match = fnmatch.fnmatch
368        result = {}
369        if not self._all:
370            self.update()
371        for key, val in self._all.items():
372            if match(key.lower(), pat):
373                result[key] = val
374        return result
375
376    def is_annex(self):
377        """Return True when git-annex is enabled"""
378        return bool(self.get('annex.uuid', default=False))
379
380    def gui_encoding(self):
381        return self.get('gui.encoding', default=None)
382
383    def is_per_file_attrs_enabled(self):
384        return self.get(
385            'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes')
386        )
387
388    def file_encoding(self, path):
389        if not self.is_per_file_attrs_enabled():
390            return self.gui_encoding()
391        cache = self._attr_cache
392        try:
393            value = cache[path]
394        except KeyError:
395            value = cache[path] = self._file_encoding(path) or self.gui_encoding()
396        return value
397
398    def _file_encoding(self, path):
399        """Return the file encoding for a path"""
400        status, out, _ = self.git.check_attr('encoding', '--', path)
401        if status != 0:
402            return None
403        header = '%s: encoding: ' % path
404        if out.startswith(header):
405            encoding = out[len(header) :].strip()
406            if encoding not in ('unspecified', 'unset', 'set'):
407                return encoding
408        return None
409
410    def get_guitool_opts(self, name):
411        """Return the guitool.<name> namespace as a dict
412
413        The dict keys are simplified so that "guitool.$name.cmd" is accessible
414        as `opts[cmd]`.
415
416        """
417        prefix = len('guitool.%s.' % name)
418        guitools = self.find('guitool.%s.*' % name)
419        return dict([(key[prefix:], value) for (key, value) in guitools.items()])
420
421    def get_guitool_names(self):
422        guitools = self.find('guitool.*.cmd')
423        prefix = len('guitool.')
424        suffix = len('.cmd')
425        return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
426
427    def get_guitool_names_and_shortcuts(self):
428        """Return guitool names and their configured shortcut"""
429        names = self.get_guitool_names()
430        return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
431
432    def terminal(self):
433        term = self.get('cola.terminal', default=None)
434        if not term:
435            # find a suitable default terminal
436            term = 'xterm -e'  # for mac osx
437            if utils.is_win32():
438                # Try to find Git's sh.exe directory in
439                # one of the typical locations
440                pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
441                pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
442                pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
443
444                for p in [pf64, pf32, pf, 'C:\\']:
445                    candidate = os.path.join(p, 'Git\\bin\\sh.exe')
446                    if os.path.isfile(candidate):
447                        return candidate
448                return None
449            else:
450                candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal')
451                for basename in candidates:
452                    if core.exists('/usr/bin/%s' % basename):
453                        if basename == 'gnome-terminal':
454                            term = '%s --' % basename
455                        else:
456                            term = '%s -e' % basename
457                        break
458        return term
459
460    def color(self, key, default):
461        value = self.get('cola.color.%s' % key, default=default)
462        struct_layout = core.encode('BBB')
463        try:
464            r, g, b = struct.unpack(struct_layout, unhex(value))
465        except (struct.error, TypeError):
466            r, g, b = struct.unpack(struct_layout, unhex(default))
467        return (r, g, b)
468
469    def hooks(self):
470        """Return the path to the git hooks directory"""
471        gitdir_hooks = self.git.git_path('hooks')
472        return self.get('core.hookspath', default=gitdir_hooks)
473
474    def hooks_path(self, *paths):
475        """Return a path from within the git hooks directory"""
476        return os.path.join(self.hooks(), *paths)
477
478
479def python_to_git(value):
480    if isinstance(value, bool):
481        return 'true' if value else 'false'
482    if isinstance(value, int_types):
483        return ustr(value)
484    return value
485