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