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