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