doc: add documentation for ~/.config/git-cola/git-bindir
[git-cola.git] / cola / gitcfg.py
blob1a6d250aabe9253a338868ef4bcfd77ce9931f33
1 from __future__ import division, absolute_import, unicode_literals
3 import copy
4 import fnmatch
5 import os
6 import re
7 import struct
8 from binascii import unhexlify
9 from os.path import join
11 from cola import core
12 from cola import git
13 from cola import observable
14 from cola.decorators import memoize
15 from cola.git import STDOUT
16 from cola.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')),
23 'git', 'config'))
25 @memoize
26 def current():
27 """Return the GitConfig singleton."""
28 return GitConfig()
31 def _stat_info():
32 # Try /etc/gitconfig as a fallback for the system config
33 paths = (('system', '/etc/gitconfig'),
34 ('user', _USER_XDG_CONFIG),
35 ('user', _USER_CONFIG),
36 ('repo', git.current().git_path('config')))
37 statinfo = []
38 for category, path in paths:
39 try:
40 statinfo.append((category, path, core.stat(path).st_mtime))
41 except OSError:
42 continue
43 return statinfo
46 def _cache_key():
47 # Try /etc/gitconfig as a fallback for the system config
48 paths = ('/etc/gitconfig',
49 _USER_XDG_CONFIG,
50 _USER_CONFIG,
51 git.current().git_path('config'))
52 mtimes = []
53 for path in paths:
54 try:
55 mtimes.append(core.stat(path).st_mtime)
56 except OSError:
57 continue
58 return mtimes
61 def _config_to_python(v):
62 """Convert a Git config string into a Python value"""
64 if v in ('true', 'yes'):
65 v = True
66 elif v in ('false', 'no'):
67 v = False
68 else:
69 try:
70 v = int(v)
71 except ValueError:
72 pass
73 return v
76 def _config_key_value(line, splitchar):
77 """Split a config line into a (key, value) pair"""
79 try:
80 k, v = line.split(splitchar, 1)
81 except ValueError:
82 # the user has a emptyentry in their git config,
83 # which Git interprets as meaning "true"
84 k = line
85 v = 'true'
86 return k, _config_to_python(v)
89 class GitConfig(observable.Observable):
90 """Encapsulate access to git-config values."""
92 message_user_config_changed = 'user_config_changed'
93 message_repo_config_changed = 'repo_config_changed'
95 def __init__(self):
96 observable.Observable.__init__(self)
97 self.git = git.current()
98 self._map = {}
99 self._system = {}
100 self._user = {}
101 self._user_or_system = {}
102 self._repo = {}
103 self._all = {}
104 self._cache_key = None
105 self._configs = []
106 self._config_files = {}
107 self._value_cache = {}
108 self._attr_cache = {}
109 self._find_config_files()
111 def reset(self):
112 self._map.clear()
113 self._system.clear()
114 self._user.clear()
115 self._user_or_system.clear()
116 self._repo.clear()
117 self._all.clear()
118 self._cache_key = None
119 self._configs = []
120 self._config_files.clear()
121 self._value_cache = {}
122 self._attr_cache = {}
123 self._find_config_files()
125 def user(self):
126 return copy.deepcopy(self._user)
128 def repo(self):
129 return copy.deepcopy(self._repo)
131 def all(self):
132 return copy.deepcopy(self._all)
134 def _find_config_files(self):
136 Classify git config files into 'system', 'user', and 'repo'.
138 Populates self._configs with a list of the files in
139 reverse-precedence order. self._config_files is populated with
140 {category: path} where category is one of 'system', 'user', or 'repo'.
143 # Try the git config in git's installation prefix
144 statinfo = _stat_info()
145 self._configs = map(lambda x: x[1], statinfo)
146 self._config_files = {}
147 for (cat, path, mtime) in statinfo:
148 self._config_files[cat] = path
150 def update(self):
151 """Read config values from git."""
152 if self._cached():
153 return
154 self._read_configs()
156 def _cached(self):
158 Return True when the cache matches.
160 Updates the cache and returns False when the cache does not match.
163 cache_key = _cache_key()
164 if self._cache_key is None or cache_key != self._cache_key:
165 self._cache_key = cache_key
166 return False
167 return True
169 def _read_configs(self):
170 """Read git config value into the system, user and repo dicts."""
171 self._map.clear()
172 self._system.clear()
173 self._user.clear()
174 self._user_or_system.clear()
175 self._repo.clear()
176 self._all.clear()
178 if 'system' in self._config_files:
179 self._system.update(
180 self.read_config(self._config_files['system']))
182 if 'user' in self._config_files:
183 self._user.update(
184 self.read_config(self._config_files['user']))
186 if 'repo' in self._config_files:
187 self._repo.update(
188 self.read_config(self._config_files['repo']))
190 for dct in (self._system, self._user):
191 self._user_or_system.update(dct)
193 for dct in (self._system, self._user, self._repo):
194 self._all.update(dct)
196 def read_config(self, path):
197 """Return git config data from a path as a dictionary."""
199 if BUILTIN_READER:
200 return self._read_config_file(path)
202 dest = {}
203 args = ('--null', '--file', path, '--list')
204 config_lines = self.git.config(*args)[STDOUT].split('\0')
205 for line in config_lines:
206 if not line:
207 # the user has an invalid entry in their git config
208 continue
209 k, v = _config_key_value(line, '\n')
210 self._map[k.lower()] = k
211 dest[k] = v
212 return dest
214 def _read_config_file(self, path):
215 """Read a .gitconfig file into a dict"""
217 config = {}
218 header_simple = re.compile(r'^\[(\s+)]$')
219 header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$')
221 with core.xopen(path, 'rt') as f:
222 lines = filter(bool, [line.strip() for line in f.readlines()])
224 prefix = ''
225 for line in lines:
226 if line.startswith('#'):
227 continue
229 match = header_simple.match(line)
230 if match:
231 prefix = match.group(1) + '.'
232 continue
233 match = header_subkey.match(line)
234 if match:
235 prefix = match.group(1) + '.' + match.group(2) + '.'
236 continue
238 k, v = _config_key_value(line, '=')
239 k = prefix + k
240 self._map[k.lower()] = k
241 config[k] = v
243 return config
245 def _get(self, src, key, default):
246 self.update()
247 try:
248 value = self._get_with_fallback(src, key)
249 except KeyError:
250 value = default
251 return value
253 def _get_with_fallback(self, src, key):
254 try:
255 return src[key]
256 except KeyError:
257 pass
258 key = self._map.get(key.lower(), key)
259 try:
260 return src[key]
261 except KeyError:
262 pass
263 # Allow the final KeyError to bubble up
264 return src[key.lower()]
266 def get(self, key, default=None):
267 """Return the string value for a config key."""
268 return self._get(self._all, key, default)
270 def get_user(self, key, default=None):
271 return self._get(self._user, key, default)
273 def get_repo(self, key, default=None):
274 return self._get(self._repo, key, default)
276 def get_user_or_system(self, key, default=None):
277 return self._get(self._user_or_system, key, default)
279 def python_to_git(self, value):
280 if type(value) is bool:
281 if value:
282 return 'true'
283 else:
284 return 'false'
285 if type(value) is int:
286 return ustr(value)
287 return value
289 def set_user(self, key, value):
290 if value is None:
291 self.unset_user(key)
292 return
293 self.git.config('--global', key, self.python_to_git(value))
294 self.update()
295 msg = self.message_user_config_changed
296 self.notify_observers(msg, key, value)
298 def set_repo(self, key, value):
299 self.git.config(key, self.python_to_git(value))
300 self.update()
301 msg = self.message_repo_config_changed
302 self.notify_observers(msg, key, value)
304 def unset_user(self, key):
305 self.git.config('--global', '--unset', key)
306 self.update()
307 msg = self.message_repo_config_changed
308 self.notify_observers(msg, key, None)
310 def find(self, pat):
311 pat = pat.lower()
312 match = fnmatch.fnmatch
313 result = {}
314 self.update()
315 for key, val in self._all.items():
316 if match(key.lower(), pat):
317 result[key] = val
318 return result
320 def get_cached(self, key, default=None):
321 cache = self._value_cache
322 try:
323 value = cache[key]
324 except KeyError:
325 value = cache[key] = self.get(key, default=default)
326 return value
328 def gui_encoding(self):
329 return self.get_cached('gui.encoding', default='utf-8')
331 def is_per_file_attrs_enabled(self):
332 return self.get_cached('cola.fileattributes', default=False)
334 def file_encoding(self, path):
335 if not self.is_per_file_attrs_enabled():
336 return self.gui_encoding()
337 cache = self._attr_cache
338 try:
339 value = cache[path]
340 except KeyError:
341 value = cache[path] = (self._file_encoding(path) or
342 self.gui_encoding())
343 return value
345 def _file_encoding(self, path):
346 """Return the file encoding for a path"""
347 status, out, err = self.git.check_attr('encoding', '--', path)
348 if status != 0:
349 return None
350 header = '%s: encoding: ' % path
351 if out.startswith(header):
352 encoding = out[len(header):].strip()
353 if (encoding != 'unspecified' and
354 encoding != 'unset' and
355 encoding != 'set'):
356 return encoding
357 return None
359 def get_guitool_opts(self, name):
360 """Return the guitool.<name> namespace as a dict
362 The dict keys are simplified so that "guitool.$name.cmd" is accessible
363 as `opts[cmd]`.
366 prefix = len('guitool.%s.' % name)
367 guitools = self.find('guitool.%s.*' % name)
368 return dict([(key[prefix:], value)
369 for (key, value) in guitools.items()])
371 def get_guitool_names(self):
372 guitools = self.find('guitool.*.cmd')
373 prefix = len('guitool.')
374 suffix = len('.cmd')
375 return sorted([name[prefix:-suffix]
376 for (name, cmd) in guitools.items()])
378 def get_guitool_names_and_shortcuts(self):
379 """Return guitool names and their configured shortcut"""
380 names = self.get_guitool_names()
381 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
383 def terminal(self):
384 term = self.get('cola.terminal', None)
385 if not term:
386 # find a suitable default terminal
387 term = 'xterm -e' # for mac osx
388 candidates = ('xfce4-terminal', 'konsole')
389 for basename in candidates:
390 if core.exists('/usr/bin/%s' % basename):
391 term = '%s -e' % basename
392 break
393 return term
395 def color(self, key, default):
396 string = self.get('cola.color.%s' % key, default)
397 string = core.encode(string)
398 default = core.encode(default)
399 struct_layout = core.encode('BBB')
400 try:
401 r, g, b = struct.unpack(struct_layout, unhexlify(string))
402 except Exception:
403 r, g, b = struct.unpack(struct_layout, unhexlify(default))
404 return (r, g, b)