git-cola v4.0.2
[git-cola.git] / cola / gitcfg.py
blobceff60653b8401066ee75dfd42be11a4136e4b76
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_CONFIG),
38 ('user', _USER_XDG_CONFIG),
40 config = git.git_path('config')
41 if config:
42 paths.append(('repo', config))
44 statinfo = []
45 categories_completed = set()
46 for category, path in paths:
47 # We should only read ~/.gitconfig xor ~/.config/git/config (not both).
48 # Only processing the first entry in each category to accomplish this.
49 if category in categories_completed:
50 continue
51 try:
52 statinfo.append((category, path, core.stat(path).st_mtime))
53 except OSError:
54 continue
55 categories_completed.add(category)
56 return statinfo
59 def _cache_key(git):
60 # Try /etc/gitconfig as a fallback for the system config
61 paths = [
62 '/etc/gitconfig',
63 _USER_CONFIG,
64 _USER_XDG_CONFIG,
66 config = git.git_path('config')
67 if config:
68 paths.append(config)
70 mtimes = []
71 for path in paths:
72 try:
73 mtimes.append(core.stat(path).st_mtime)
74 except OSError:
75 continue
76 return mtimes
79 def _config_to_python(v):
80 """Convert a Git config string into a Python value"""
81 if v in ('true', 'yes'):
82 v = True
83 elif v in ('false', 'no'):
84 v = False
85 else:
86 try:
87 v = int(v) # pylint: disable=redefined-variable-type
88 except ValueError:
89 pass
90 return v
93 def unhex(value):
94 """Convert a value (int or hex string) into bytes"""
95 if isinstance(value, int_types):
96 # If the value is an integer then it's a value that was converted
97 # by the config reader. Zero-pad it into a 6-digit hex number.
98 value = '%06d' % value
99 return unhexlify(core.encode(value.lstrip('#')))
102 def _config_key_value(line, splitchar):
103 """Split a config line into a (key, value) pair"""
105 try:
106 k, v = line.split(splitchar, 1)
107 except ValueError:
108 # the user has an empty entry in their git config,
109 # which Git interprets as meaning "true"
110 k = line
111 v = 'true'
112 return k, _config_to_python(v)
115 class GitConfig(QtCore.QObject):
116 """Encapsulate access to git-config values."""
118 user_config_changed = Signal(str, object)
119 repo_config_changed = Signal(str, object)
120 updated = Signal()
122 def __init__(self, context):
123 super(GitConfig, self).__init__()
124 self.context = context
125 self.git = context.git
126 self._map = {}
127 self._system = {}
128 self._user = {}
129 self._user_or_system = {}
130 self._repo = {}
131 self._all = {}
132 self._cache_key = None
133 self._configs = []
134 self._config_files = {}
135 self._attr_cache = {}
136 self._binary_cache = {}
137 self._find_config_files()
139 def reset(self):
140 self._cache_key = None
141 self._configs = []
142 self._config_files.clear()
143 self._attr_cache = {}
144 self._binary_cache = {}
145 self._find_config_files()
146 self.reset_values()
148 def reset_values(self):
149 self._map.clear()
150 self._system.clear()
151 self._user.clear()
152 self._user_or_system.clear()
153 self._repo.clear()
154 self._all.clear()
156 def user(self):
157 return copy.deepcopy(self._user)
159 def repo(self):
160 return copy.deepcopy(self._repo)
162 def all(self):
163 return copy.deepcopy(self._all)
165 def _find_config_files(self):
167 Classify git config files into 'system', 'user', and 'repo'.
169 Populates self._configs with a list of the files in
170 reverse-precedence order. self._config_files is populated with
171 {category: path} where category is one of 'system', 'user', or 'repo'.
174 # Try the git config in git's installation prefix
175 statinfo = _stat_info(self.git)
176 self._configs = [x[1] for x in statinfo]
177 self._config_files = {}
178 for (cat, path, _) in statinfo:
179 self._config_files[cat] = path
181 def _cached(self):
183 Return True when the cache matches.
185 Updates the cache and returns False when the cache does not match.
188 cache_key = _cache_key(self.git)
189 if self._cache_key is None or cache_key != self._cache_key:
190 self._cache_key = cache_key
191 return False
192 return True
194 def update(self):
195 """Read git config value into the system, user and repo dicts."""
196 if self._cached():
197 return
199 self.reset_values()
201 if 'system' in self._config_files:
202 self._system.update(self.read_config(self._config_files['system']))
204 if 'user' in self._config_files:
205 self._user.update(self.read_config(self._config_files['user']))
207 if 'repo' in self._config_files:
208 self._repo.update(self.read_config(self._config_files['repo']))
210 for dct in (self._system, self._user):
211 self._user_or_system.update(dct)
213 for dct in (self._system, self._user, self._repo):
214 self._all.update(dct)
216 self.updated.emit()
218 def read_config(self, path):
219 """Return git config data from a path as a dictionary."""
221 if BUILTIN_READER:
222 return self._read_config_file(path)
224 dest = {}
225 if version.check_git(self.context, 'config-includes'):
226 args = ('--null', '--file', path, '--list', '--includes')
227 else:
228 args = ('--null', '--file', path, '--list')
229 config_lines = self.git.config(_readonly=True, *args)[STDOUT].split('\0')
230 for line in config_lines:
231 if not line:
232 # the user has an invalid entry in their git config
233 continue
234 k, v = _config_key_value(line, '\n')
235 self._map[k.lower()] = k
236 dest[k] = v
237 return dest
239 def _read_config_file(self, path):
240 """Read a .gitconfig file into a dict"""
242 config = {}
243 header_simple = re.compile(r'^\[(\s+)]$')
244 header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$')
246 with core.xopen(path, 'rt') as f:
247 file_lines = f.readlines()
249 stripped_lines = [line.strip() for line in file_lines]
250 lines = [line for line in stripped_lines if bool(line)]
251 prefix = ''
252 for line in lines:
253 if line.startswith('#'):
254 continue
256 match = header_simple.match(line)
257 if match:
258 prefix = match.group(1) + '.'
259 continue
260 match = header_subkey.match(line)
261 if match:
262 prefix = match.group(1) + '.' + match.group(2) + '.'
263 continue
265 k, v = _config_key_value(line, '=')
266 k = prefix + k
267 self._map[k.lower()] = k
268 config[k] = v
270 return config
272 def _get(self, src, key, default, fn=None, cached=True):
273 if not cached or not src:
274 self.update()
275 try:
276 value = self._get_with_fallback(src, key)
277 except KeyError:
278 if fn:
279 value = fn()
280 else:
281 value = default
282 return value
284 def _get_with_fallback(self, src, key):
285 try:
286 return src[key]
287 except KeyError:
288 pass
289 key = self._map.get(key.lower(), key)
290 try:
291 return src[key]
292 except KeyError:
293 pass
294 # Allow the final KeyError to bubble up
295 return src[key.lower()]
297 def get(self, key, default=None, fn=None, cached=True):
298 """Return the string value for a config key."""
299 return self._get(self._all, key, default, fn=fn, cached=cached)
301 def get_all(self, key):
302 """Return all values for a key sorted in priority order
304 The purpose of this function is to group the values returned by
305 `git config --show-origin --get-all` so that the relative order is
306 preserved but can still be overridden at each level.
308 One use case is the `cola.icontheme` variable, which is an ordered
309 list of icon themes to load. This value can be set both in
310 ~/.gitconfig as well as .git/config, and we want to allow a
311 relative order to be defined in either file.
313 The problem is that git will read the system /etc/gitconfig,
314 global ~/.gitconfig, and then the local .git/config settings
315 and return them in that order, so we must post-process them to
316 get them in an order which makes sense for use for our values.
317 Otherwise, we cannot replace the order, or make a specific theme used
318 first, in our local .git/config since the native order returned by
319 git will always list the global config before the local one.
321 get_all() allows for this use case by gathering all of the per-config
322 values separately and then orders them according to the expected
323 local > user > system precedence order.
326 result = []
327 status, out, _ = self.git.config(
328 key, z=True, get_all=True, show_origin=True, _readonly=True
330 if status == 0:
331 current_source = ''
332 current_result = []
333 partial_results = []
334 items = [x for x in out.rstrip(chr(0)).split(chr(0)) if x]
335 for i in range(len(items) // 2):
336 source = items[i * 2]
337 value = items[i * 2 + 1]
338 if source != current_source:
339 current_source = source
340 current_result = []
341 partial_results.append(current_result)
342 current_result.append(value)
343 # Git's results are ordered System, Global, Local.
344 # Reverse the order here so that Local has the highest priority.
345 for partial_result in reversed(partial_results):
346 result.extend(partial_result)
348 return result
350 def get_user(self, key, default=None):
351 return self._get(self._user, key, default)
353 def get_repo(self, key, default=None):
354 return self._get(self._repo, key, default)
356 def get_user_or_system(self, key, default=None):
357 return self._get(self._user_or_system, key, default)
359 def set_user(self, key, value):
360 if value in (None, ''):
361 self.git.config('--global', key, unset=True, _readonly=True)
362 else:
363 self.git.config('--global', key, python_to_git(value), _readonly=True)
364 self.update()
365 self.user_config_changed.emit(key, value)
367 def set_repo(self, key, value):
368 if value in (None, ''):
369 self.git.config(key, unset=True, _readonly=True)
370 else:
371 self.git.config(key, python_to_git(value), _readonly=True)
372 self.update()
373 self.repo_config_changed.emit(key, value)
375 def find(self, pat):
376 """Return a a dict of values for all keys matching the specified pattern"""
377 pat = pat.lower()
378 match = fnmatch.fnmatch
379 result = {}
380 if not self._all:
381 self.update()
382 for key, val in self._all.items():
383 if match(key.lower(), pat):
384 result[key] = val
385 return result
387 def is_annex(self):
388 """Return True when git-annex is enabled"""
389 return bool(self.get('annex.uuid', default=False))
391 def gui_encoding(self):
392 return self.get('gui.encoding', default=None)
394 def is_per_file_attrs_enabled(self):
395 return self.get(
396 'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes')
399 def is_binary(self, path):
400 """Return True if the file has the binary attribute set"""
401 if not self.is_per_file_attrs_enabled():
402 return None
403 cache = self._binary_cache
404 try:
405 value = cache[path]
406 except KeyError:
407 value = cache[path] = self._is_binary(path)
408 return value
410 def _is_binary(self, path):
411 """Return the file encoding for a path"""
412 value = self.check_attr('binary', path)
413 return value == 'set'
415 def file_encoding(self, path):
416 if not self.is_per_file_attrs_enabled():
417 return self.gui_encoding()
418 cache = self._attr_cache
419 try:
420 value = cache[path]
421 except KeyError:
422 value = cache[path] = self._file_encoding(path) or self.gui_encoding()
423 return value
425 def _file_encoding(self, path):
426 """Return the file encoding for a path"""
427 encoding = self.check_attr('encoding', path)
428 if encoding in ('unspecified', 'unset', 'set'):
429 result = None
430 else:
431 result = encoding
432 return result
434 def check_attr(self, attr, path):
435 """Check file attributes for a path"""
436 value = None
437 status, out, _ = self.git.check_attr(attr, '--', path, _readonly=True)
438 if status == 0:
439 header = '%s: %s: ' % (path, attr)
440 if out.startswith(header):
441 value = out[len(header) :].strip()
442 return value
444 def get_guitool_opts(self, name):
445 """Return the guitool.<name> namespace as a dict
447 The dict keys are simplified so that "guitool.$name.cmd" is accessible
448 as `opts[cmd]`.
451 prefix = len('guitool.%s.' % name)
452 guitools = self.find('guitool.%s.*' % name)
453 return dict([(key[prefix:], value) for (key, value) in guitools.items()])
455 def get_guitool_names(self):
456 guitools = self.find('guitool.*.cmd')
457 prefix = len('guitool.')
458 suffix = len('.cmd')
459 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
461 def get_guitool_names_and_shortcuts(self):
462 """Return guitool names and their configured shortcut"""
463 names = self.get_guitool_names()
464 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
466 def terminal(self):
467 term = self.get('cola.terminal', default=None)
468 if not term:
469 # find a suitable default terminal
470 term = 'xterm -e' # for mac osx
471 if utils.is_win32():
472 # Try to find Git's sh.exe directory in
473 # one of the typical locations
474 pf = os.environ.get('ProgramFiles', r'C:\Program Files')
475 pf32 = os.environ.get('ProgramFiles(x86)', r'C:\Program Files (x86)')
476 pf64 = os.environ.get('ProgramW6432', r'C:\Program Files')
478 for p in [pf64, pf32, pf, 'C:\\']:
479 candidate = os.path.join(p, r'Git\bin\sh.exe')
480 if os.path.isfile(candidate):
481 return candidate
482 return None
483 else:
484 candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal')
485 for basename in candidates:
486 if core.exists('/usr/bin/%s' % basename):
487 if basename == 'gnome-terminal':
488 term = '%s --' % basename
489 else:
490 term = '%s -e' % basename
491 break
492 return term
494 def color(self, key, default):
495 value = self.get('cola.color.%s' % key, default=default)
496 struct_layout = core.encode('BBB')
497 try:
498 # pylint: disable=no-member
499 r, g, b = struct.unpack(struct_layout, unhex(value))
500 except (struct.error, TypeError):
501 # pylint: disable=no-member
502 r, g, b = struct.unpack(struct_layout, unhex(default))
503 return (r, g, b)
505 def hooks(self):
506 """Return the path to the git hooks directory"""
507 gitdir_hooks = self.git.git_path('hooks')
508 return self.get('core.hookspath', default=gitdir_hooks)
510 def hooks_path(self, *paths):
511 """Return a path from within the git hooks directory"""
512 return os.path.join(self.hooks(), *paths)
515 def python_to_git(value):
516 if isinstance(value, bool):
517 return 'true' if value else 'false'
518 if isinstance(value, int_types):
519 return ustr(value)
520 return value
523 def get_remotes(cfg):
524 """Get all of the configured git remotes"""
525 # Gather all of the remote.*.url entries.
526 prefix = len('remote.')
527 suffix = len('.url')
528 return sorted(key[prefix:-suffix] for key in cfg.find('remote.*.url'))