tests: use pytest fixtures in browse_model_test
[git-cola.git] / cola / gitcfg.py
blob9c63a1a14a9fa54fd221c699e68defddb46ebc8b
1 from __future__ import division, absolute_import, 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 . import core
11 from . import observable
12 from . import utils
13 from . import version
14 from .compat import int_types
15 from .git import STDOUT
16 from .compat import ustr
18 BUILTIN_READER = os.environ.get('GIT_COLA_BUILTIN_CONFIG_READER', False)
20 _USER_CONFIG = core.expanduser(join('~', '.gitconfig'))
21 _USER_XDG_CONFIG = core.expanduser(
22 join(core.getenv('XDG_CONFIG_HOME', join('~', '.config')), 'git', 'config')
26 def create(context):
27 """Create GitConfig instances"""
28 return GitConfig(context)
31 def _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),
38 config = git.git_path('config')
39 if config:
40 paths.append(('repo', config))
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
51 def _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,
58 config = git.git_path('config')
59 if config:
60 paths.append(config)
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
71 def _config_to_python(v):
72 """Convert a Git config string into a Python value"""
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
86 def 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('#')))
95 def _config_key_value(line, splitchar):
96 """Split a config line into a (key, value) pair"""
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)
108 class GitConfig(observable.Observable):
109 """Encapsulate access to git-config values."""
111 message_user_config_changed = 'user_config_changed'
112 message_repo_config_changed = 'repo_config_changed'
113 message_updated = 'updated'
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._binary_cache = {}
129 self._find_config_files()
131 def reset(self):
132 self._cache_key = None
133 self._configs = []
134 self._config_files.clear()
135 self._attr_cache = {}
136 self._binary_cache = {}
137 self._find_config_files()
138 self.reset_values()
140 def reset_values(self):
141 self._map.clear()
142 self._system.clear()
143 self._user.clear()
144 self._user_or_system.clear()
145 self._repo.clear()
146 self._all.clear()
148 def user(self):
149 return copy.deepcopy(self._user)
151 def repo(self):
152 return copy.deepcopy(self._repo)
154 def all(self):
155 return copy.deepcopy(self._all)
157 def _find_config_files(self):
159 Classify git config files into 'system', 'user', and 'repo'.
161 Populates self._configs with a list of the files in
162 reverse-precedence order. self._config_files is populated with
163 {category: path} where category is one of 'system', 'user', or 'repo'.
166 # Try the git config in git's installation prefix
167 statinfo = _stat_info(self.git)
168 self._configs = [x[1] for x in statinfo]
169 self._config_files = {}
170 for (cat, path, _) in statinfo:
171 self._config_files[cat] = path
173 def _cached(self):
175 Return True when the cache matches.
177 Updates the cache and returns False when the cache does not match.
180 cache_key = _cache_key(self.git)
181 if self._cache_key is None or cache_key != self._cache_key:
182 self._cache_key = cache_key
183 return False
184 return True
186 def update(self):
187 """Read git config value into the system, user and repo dicts."""
188 if self._cached():
189 return
191 self.reset_values()
193 if 'system' in self._config_files:
194 self._system.update(self.read_config(self._config_files['system']))
196 if 'user' in self._config_files:
197 self._user.update(self.read_config(self._config_files['user']))
199 if 'repo' in self._config_files:
200 self._repo.update(self.read_config(self._config_files['repo']))
202 for dct in (self._system, self._user):
203 self._user_or_system.update(dct)
205 for dct in (self._system, self._user, self._repo):
206 self._all.update(dct)
208 self.notify_observers(self.message_updated)
210 def read_config(self, path):
211 """Return git config data from a path as a dictionary."""
213 if BUILTIN_READER:
214 return self._read_config_file(path)
216 dest = {}
217 if version.check_git(self, 'config-includes'):
218 args = ('--null', '--file', path, '--list', '--includes')
219 else:
220 args = ('--null', '--file', path, '--list')
221 config_lines = self.git.config(*args)[STDOUT].split('\0')
222 for line in config_lines:
223 if not line:
224 # the user has an invalid entry in their git config
225 continue
226 k, v = _config_key_value(line, '\n')
227 self._map[k.lower()] = k
228 dest[k] = v
229 return dest
231 def _read_config_file(self, path):
232 """Read a .gitconfig file into a dict"""
234 config = {}
235 header_simple = re.compile(r'^\[(\s+)]$')
236 header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$')
238 with core.xopen(path, 'rt') as f:
239 file_lines = f.readlines()
241 stripped_lines = [line.strip() for line in file_lines]
242 lines = [line for line in stripped_lines if bool(line)]
243 prefix = ''
244 for line in lines:
245 if line.startswith('#'):
246 continue
248 match = header_simple.match(line)
249 if match:
250 prefix = match.group(1) + '.'
251 continue
252 match = header_subkey.match(line)
253 if match:
254 prefix = match.group(1) + '.' + match.group(2) + '.'
255 continue
257 k, v = _config_key_value(line, '=')
258 k = prefix + k
259 self._map[k.lower()] = k
260 config[k] = v
262 return config
264 def _get(self, src, key, default, fn=None, cached=True):
265 if not cached or not src:
266 self.update()
267 try:
268 value = self._get_with_fallback(src, key)
269 except KeyError:
270 if fn:
271 value = fn()
272 else:
273 value = default
274 return value
276 def _get_with_fallback(self, src, key):
277 try:
278 return src[key]
279 except KeyError:
280 pass
281 key = self._map.get(key.lower(), key)
282 try:
283 return src[key]
284 except KeyError:
285 pass
286 # Allow the final KeyError to bubble up
287 return src[key.lower()]
289 def get(self, key, default=None, fn=None, cached=True):
290 """Return the string value for a config key."""
291 return self._get(self._all, key, default, fn=fn, cached=cached)
293 def get_all(self, key):
294 """Return all values for a key sorted in priority order
296 The purpose of this function is to group the values returned by
297 `git config --show-origin --get-all` so that the relative order is
298 preserved but can still be overridden at each level.
300 One use case is the `cola.icontheme` variable, which is an ordered
301 list of icon themes to load. This value can be set both in
302 ~/.gitconfig as well as .git/config, and we want to allow a
303 relative order to be defined in either file.
305 The problem is that git will read the system /etc/gitconfig,
306 global ~/.gitconfig, and then the local .git/config settings
307 and return them in that order, so we must post-process them to
308 get them in an order which makes sense for use for our values.
309 Otherwise, we cannot replace the order, or make a specific theme used
310 first, in our local .git/config since the native order returned by
311 git will always list the global config before the local one.
313 get_all() allows for this use case by gathering all of the per-config
314 values separately and then orders them according to the expected
315 local > global > system order.
318 result = []
319 status, out, _ = self.git.config(key, z=True, get_all=True, show_origin=True)
320 if status == 0:
321 current_source = ''
322 current_result = []
323 partial_results = []
324 items = [x for x in out.rstrip(chr(0)).split(chr(0)) if x]
325 for i in range(len(items) // 2):
326 source = items[i * 2]
327 value = items[i * 2 + 1]
328 if source != current_source:
329 current_source = source
330 current_result = []
331 partial_results.append(current_result)
332 current_result.append(value)
333 # Git's results are ordered System, Global, Local.
334 # Reverse the order here so that Local has the highest priority.
335 for partial_result in reversed(partial_results):
336 result.extend(partial_result)
338 return result
340 def get_user(self, key, default=None):
341 return self._get(self._user, key, default)
343 def get_repo(self, key, default=None):
344 return self._get(self._repo, key, default)
346 def get_user_or_system(self, key, default=None):
347 return self._get(self._user_or_system, key, default)
349 def set_user(self, key, value):
350 if value in (None, ''):
351 self.git.config('--global', key, unset=True)
352 else:
353 self.git.config('--global', key, python_to_git(value))
354 self.update()
355 msg = self.message_user_config_changed
356 self.notify_observers(msg, 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 msg = self.message_repo_config_changed
365 self.notify_observers(msg, key, value)
367 def find(self, pat):
368 pat = pat.lower()
369 match = fnmatch.fnmatch
370 result = {}
371 if not self._all:
372 self.update()
373 for key, val in self._all.items():
374 if match(key.lower(), pat):
375 result[key] = val
376 return result
378 def is_annex(self):
379 """Return True when git-annex is enabled"""
380 return bool(self.get('annex.uuid', default=False))
382 def gui_encoding(self):
383 return self.get('gui.encoding', default=None)
385 def is_per_file_attrs_enabled(self):
386 return self.get(
387 'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes')
390 def is_binary(self, path):
391 """Return True if the file has the binary attribute set"""
392 if not self.is_per_file_attrs_enabled():
393 return None
394 cache = self._binary_cache
395 try:
396 value = cache[path]
397 except KeyError:
398 value = cache[path] = self._is_binary(path)
399 return value
401 def _is_binary(self, path):
402 """Return the file encoding for a path"""
403 value = self.check_attr('binary', path)
404 return value == 'set'
406 def file_encoding(self, path):
407 if not self.is_per_file_attrs_enabled():
408 return self.gui_encoding()
409 cache = self._attr_cache
410 try:
411 value = cache[path]
412 except KeyError:
413 value = cache[path] = self._file_encoding(path) or self.gui_encoding()
414 return value
416 def _file_encoding(self, path):
417 """Return the file encoding for a path"""
418 encoding = self.check_attr('encoding', path)
419 if encoding in ('unspecified', 'unset', 'set'):
420 result = None
421 else:
422 result = encoding
423 return result
425 def check_attr(self, attr, path):
426 """Check file attributes for a path"""
427 value = None
428 status, out, _ = self.git.check_attr(attr, '--', path)
429 if status == 0:
430 header = '%s: %s: ' % (path, attr)
431 if out.startswith(header):
432 value = out[len(header) :].strip()
433 return value
435 def get_guitool_opts(self, name):
436 """Return the guitool.<name> namespace as a dict
438 The dict keys are simplified so that "guitool.$name.cmd" is accessible
439 as `opts[cmd]`.
442 prefix = len('guitool.%s.' % name)
443 guitools = self.find('guitool.%s.*' % name)
444 return dict([(key[prefix:], value) for (key, value) in guitools.items()])
446 def get_guitool_names(self):
447 guitools = self.find('guitool.*.cmd')
448 prefix = len('guitool.')
449 suffix = len('.cmd')
450 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
452 def get_guitool_names_and_shortcuts(self):
453 """Return guitool names and their configured shortcut"""
454 names = self.get_guitool_names()
455 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
457 def terminal(self):
458 term = self.get('cola.terminal', default=None)
459 if not term:
460 # find a suitable default terminal
461 term = 'xterm -e' # for mac osx
462 if utils.is_win32():
463 # Try to find Git's sh.exe directory in
464 # one of the typical locations
465 pf = os.environ.get('ProgramFiles', 'C:\\Program Files')
466 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)')
467 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files')
469 for p in [pf64, pf32, pf, 'C:\\']:
470 candidate = os.path.join(p, 'Git\\bin\\sh.exe')
471 if os.path.isfile(candidate):
472 return candidate
473 return None
474 else:
475 candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal')
476 for basename in candidates:
477 if core.exists('/usr/bin/%s' % basename):
478 if basename == 'gnome-terminal':
479 term = '%s --' % basename
480 else:
481 term = '%s -e' % basename
482 break
483 return term
485 def color(self, key, default):
486 value = self.get('cola.color.%s' % key, default=default)
487 struct_layout = core.encode('BBB')
488 try:
489 r, g, b = struct.unpack(struct_layout, unhex(value))
490 except (struct.error, TypeError):
491 r, g, b = struct.unpack(struct_layout, unhex(default))
492 return (r, g, b)
494 def hooks(self):
495 """Return the path to the git hooks directory"""
496 gitdir_hooks = self.git.git_path('hooks')
497 return self.get('core.hookspath', default=gitdir_hooks)
499 def hooks_path(self, *paths):
500 """Return a path from within the git hooks directory"""
501 return os.path.join(self.hooks(), *paths)
504 def python_to_git(value):
505 if isinstance(value, bool):
506 return 'true' if value else 'false'
507 if isinstance(value, int_types):
508 return ustr(value)
509 return value