gitcfg: refactor config reading into a function
[git-cola.git] / cola / gitcfg.py
blob59b097e5102625a910729a5c0f6800df3ed6fadd
1 from __future__ import absolute_import, division, print_function, unicode_literals
2 from binascii import unhexlify
3 import collections
4 import copy
5 import fnmatch
6 import os
7 import struct
9 from qtpy import QtCore
10 from qtpy.QtCore import Signal
12 from . import core
13 from . import utils
14 from . import version
15 from .compat import int_types
16 from .compat import ustr
19 def create(context):
20 """Create GitConfig instances"""
21 return GitConfig(context)
24 def _cache_key_from_paths(paths):
25 """Return a stat cache from the given paths"""
26 if not paths:
27 return None
28 mtimes = []
29 for path in sorted(paths):
30 try:
31 mtimes.append(core.stat(path).st_mtime)
32 except OSError:
33 continue
34 if mtimes:
35 return mtimes
36 return None
39 def _config_to_python(v):
40 """Convert a Git config string into a Python value"""
41 if v in ('true', 'yes'):
42 v = True
43 elif v in ('false', 'no'):
44 v = False
45 else:
46 try:
47 v = int(v) # pylint: disable=redefined-variable-type
48 except ValueError:
49 pass
50 return v
53 def unhex(value):
54 """Convert a value (int or hex string) into bytes"""
55 if isinstance(value, int_types):
56 # If the value is an integer then it's a value that was converted
57 # by the config reader. Zero-pad it into a 6-digit hex number.
58 value = '%06d' % value
59 return unhexlify(core.encode(value.lstrip('#')))
62 def _config_key_value(line, splitchar):
63 """Split a config line into a (key, value) pair"""
64 try:
65 k, v = line.split(splitchar, 1)
66 except ValueError:
67 # the user has an empty entry in their git config,
68 # which Git interprets as meaning "true"
69 k = line
70 v = 'true'
71 return k, _config_to_python(v)
74 def _append_tab(value):
75 """Return a value and the same value with tab appended"""
76 return (value, value + '\t')
79 class GitConfig(QtCore.QObject):
80 """Encapsulate access to git-config values."""
82 user_config_changed = Signal(str, object)
83 repo_config_changed = Signal(str, object)
84 updated = Signal()
86 def __init__(self, context):
87 super(GitConfig, self).__init__()
88 self.context = context
89 self.git = context.git
90 self._system = {}
91 self._global = {}
92 self._global_or_system = {}
93 self._local = {}
94 self._all = {}
95 self._renamed_keys = {}
96 self._multi_values = collections.defaultdict(list)
97 self._cache_key = None
98 self._cache_paths = []
99 self._attr_cache = {}
100 self._binary_cache = {}
102 def reset(self):
103 self._cache_key = None
104 self._cache_paths = []
105 self._attr_cache.clear()
106 self._binary_cache.clear()
107 self.reset_values()
109 def reset_values(self):
110 self._system.clear()
111 self._global.clear()
112 self._global_or_system.clear()
113 self._local.clear()
114 self._all.clear()
115 self._renamed_keys.clear()
116 self._multi_values.clear()
118 def user(self):
119 return copy.deepcopy(self._global)
121 def repo(self):
122 return copy.deepcopy(self._local)
124 def all(self):
125 return copy.deepcopy(self._all)
127 def _is_cached(self):
129 Return True when the cache matches.
131 Updates the cache and returns False when the cache does not match.
134 cache_key = _cache_key_from_paths(self._cache_paths)
135 return self._cache_key and cache_key == self._cache_key
137 def update(self):
138 """Read git config value into the system, user and repo dicts."""
139 if self._is_cached():
140 return
142 self.reset_values()
144 show_scope = version.check_git(self.context, 'config-show-scope')
145 status, config_output, _ = self.git.config(
146 show_origin=True, show_scope=show_scope, list=True, includes=True
148 if status != 0:
149 return
151 if show_scope:
152 reader = _read_config_with_scope
153 else:
154 reader = _read_config_with_scope
156 unknown_scope = 'unknown'
157 system_scope = 'system'
158 global_scope = 'global'
159 local_scope = 'local'
160 worktree_scope = 'worktree'
161 cache_paths = set()
163 for (
164 current_scope, current_key, current_value, continuation
165 ) in reader(config_output, cache_paths, self._renamed_keys):
166 # Store the values for fast cached lookup.
167 self._all[current_key] = current_value
169 # macOS has credential.helper=osxkeychain in the "unknown" scope from
170 # /Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig.
171 # Treat "unknown" as equivalent to "system" (lowest priority).
172 if current_scope in (system_scope, unknown_scope):
173 self._system[current_key] = current_value
174 self._global_or_system[current_key] = current_value
175 elif current_scope == global_scope:
176 self._global[current_key] = current_value
177 self._global_or_system[current_key] = current_value
178 # "worktree" is treated as equivalent to "local".
179 elif current_scope in (local_scope, worktree_scope):
180 self._local[current_key] = current_value
182 # Add this value to the multi-values storage used by get_all().
183 # This allows us to handle keys that store multiple values.
184 if continuation:
185 # If this is a continuation line then we should *not* append to its
186 # multi-values list. We should update it in-place.
187 self._multi_values[current_key][-1] = current_value
188 else:
189 self._multi_values[current_key].append(current_value)
191 # Update the cache
192 self._cache_paths = sorted(cache_paths)
193 self._cache_key = _cache_key_from_paths(self._cache_paths)
195 # Send a notification that the configuration has been updated.
196 self.updated.emit()
198 def _get(self, src, key, default, fn=None, cached=True):
199 if not cached or not src:
200 self.update()
201 try:
202 value = self._get_value(src, key)
203 except KeyError:
204 if fn:
205 value = fn()
206 else:
207 value = default
208 return value
210 def _get_value(self, src, key):
211 """Return a value from the map"""
212 try:
213 return src[key]
214 except KeyError:
215 pass
216 # Try the original key name.
217 key = self._renamed_keys.get(key.lower(), key)
218 try:
219 return src[key]
220 except KeyError:
221 pass
222 # Allow the final KeyError to bubble up
223 return src[key.lower()]
225 def get(self, key, default=None, fn=None, cached=True):
226 """Return the string value for a config key."""
227 return self._get(self._all, key, default, fn=fn, cached=cached)
229 def get_all(self, key):
230 """Return all values for a key sorted in priority order
232 The purpose of this function is to group the values returned by
233 `git config --show-origin --list` so that the relative order is
234 preserved and can be overridden at each level.
236 One use case is the `cola.icontheme` variable, which is an ordered
237 list of icon themes to load. This value can be set both in
238 ~/.gitconfig as well as .git/config, and we want to allow a
239 relative order to be defined in either file.
241 The problem is that git will read the system /etc/gitconfig,
242 global ~/.gitconfig, and then the local .git/config settings
243 and return them in that order, so we must post-process them to
244 get them in an order which makes sense for use for our values.
245 Otherwise, we cannot replace the order, or make a specific theme used
246 first, in our local .git/config since the native order returned by
247 git will always list the global config before the local one.
249 get_all() allows for this use case by reading from a defaultdict
250 that contains all of the per-config values separately so that the
251 caller can order them according to its preferred precedence.
253 if not self._multi_values:
254 self.update()
255 # Check for this key as-is.
256 if key in self._multi_values:
257 return self._multi_values[key]
259 # Check for a renamed version of this key (x.kittycat -> x.kittyCat)
260 renamed_key = self._renamed_keys.get(key.lower(), key)
261 if renamed_key in self._multi_values:
262 return self._multi_values[renamed_key]
264 key_lower = key.lower()
265 if key_lower in self._multi_values:
266 return self._multi_values[key_lower]
267 # Nothing found -> empty list.
268 return []
270 def get_user(self, key, default=None):
271 return self._get(self._global, key, default)
273 def get_repo(self, key, default=None):
274 return self._get(self._local, key, default)
276 def get_user_or_system(self, key, default=None):
277 return self._get(self._global_or_system, key, default)
279 def set_user(self, key, value):
280 if value in (None, ''):
281 self.git.config('--global', key, unset=True, _readonly=True)
282 else:
283 self.git.config('--global', key, python_to_git(value), _readonly=True)
284 self.update()
285 self.user_config_changed.emit(key, value)
287 def set_repo(self, key, value):
288 if value in (None, ''):
289 self.git.config(key, unset=True, _readonly=True)
290 else:
291 self.git.config(key, python_to_git(value), _readonly=True)
292 self.update()
293 self.repo_config_changed.emit(key, value)
295 def find(self, pat):
296 """Return a a dict of values for all keys matching the specified pattern"""
297 pat = pat.lower()
298 match = fnmatch.fnmatch
299 result = {}
300 if not self._all:
301 self.update()
302 for key, val in self._all.items():
303 if match(key.lower(), pat):
304 result[key] = val
305 return result
307 def is_annex(self):
308 """Return True when git-annex is enabled"""
309 return bool(self.get('annex.uuid', default=False))
311 def gui_encoding(self):
312 return self.get('gui.encoding', default=None)
314 def is_per_file_attrs_enabled(self):
315 return self.get(
316 'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes')
319 def is_binary(self, path):
320 """Return True if the file has the binary attribute set"""
321 if not self.is_per_file_attrs_enabled():
322 return None
323 cache = self._binary_cache
324 try:
325 value = cache[path]
326 except KeyError:
327 value = cache[path] = self._is_binary(path)
328 return value
330 def _is_binary(self, path):
331 """Return the file encoding for a path"""
332 value = self.check_attr('binary', path)
333 return value == 'set'
335 def file_encoding(self, path):
336 if not self.is_per_file_attrs_enabled():
337 return self.gui_encoding()
338 cache = self._attr_cache
339 try:
340 value = cache[path]
341 except KeyError:
342 value = cache[path] = self._file_encoding(path) or self.gui_encoding()
343 return value
345 def _file_encoding(self, path):
346 """Return the file encoding for a path"""
347 encoding = self.check_attr('encoding', path)
348 if encoding in ('unspecified', 'unset', 'set'):
349 result = None
350 else:
351 result = encoding
352 return result
354 def check_attr(self, attr, path):
355 """Check file attributes for a path"""
356 value = None
357 status, out, _ = self.git.check_attr(attr, '--', path, _readonly=True)
358 if status == 0:
359 header = '%s: %s: ' % (path, attr)
360 if out.startswith(header):
361 value = out[len(header) :].strip()
362 return value
364 def get_guitool_opts(self, name):
365 """Return the guitool.<name> namespace as a dict
367 The dict keys are simplified so that "guitool.$name.cmd" is accessible
368 as `opts[cmd]`.
371 prefix = len('guitool.%s.' % name)
372 guitools = self.find('guitool.%s.*' % name)
373 return dict([(key[prefix:], value) for (key, value) in guitools.items()])
375 def get_guitool_names(self):
376 guitools = self.find('guitool.*.cmd')
377 prefix = len('guitool.')
378 suffix = len('.cmd')
379 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
381 def get_guitool_names_and_shortcuts(self):
382 """Return guitool names and their configured shortcut"""
383 names = self.get_guitool_names()
384 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
386 def terminal(self):
387 term = self.get('cola.terminal', default=None)
388 if not term:
389 # find a suitable default terminal
390 term = 'xterm -e' # for mac osx
391 if utils.is_win32():
392 # Try to find Git's sh.exe directory in
393 # one of the typical locations
394 pf = os.environ.get('ProgramFiles', r'C:\Program Files')
395 pf32 = os.environ.get('ProgramFiles(x86)', r'C:\Program Files (x86)')
396 pf64 = os.environ.get('ProgramW6432', r'C:\Program Files')
398 for p in [pf64, pf32, pf, 'C:\\']:
399 candidate = os.path.join(p, r'Git\bin\sh.exe')
400 if os.path.isfile(candidate):
401 return candidate
402 return None
403 else:
404 candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal')
405 for basename in candidates:
406 if core.exists('/usr/bin/%s' % basename):
407 if basename == 'gnome-terminal':
408 term = '%s --' % basename
409 else:
410 term = '%s -e' % basename
411 break
412 return term
414 def color(self, key, default):
415 value = self.get('cola.color.%s' % key, default=default)
416 struct_layout = core.encode('BBB')
417 try:
418 # pylint: disable=no-member
419 r, g, b = struct.unpack(struct_layout, unhex(value))
420 except (struct.error, TypeError):
421 # pylint: disable=no-member
422 r, g, b = struct.unpack(struct_layout, unhex(default))
423 return (r, g, b)
425 def hooks(self):
426 """Return the path to the git hooks directory"""
427 gitdir_hooks = self.git.git_path('hooks')
428 return self.get('core.hookspath', default=gitdir_hooks)
430 def hooks_path(self, *paths):
431 """Return a path from within the git hooks directory"""
432 return os.path.join(self.hooks(), *paths)
435 def _read_config_with_scope(config_output, cache_paths, renamed_keys):
436 """Read the output from "git config --show-scope --show-origin --list
438 ``--show-scope`` was introduced in Git v2.26.0.
440 unknown_key = 'unknown\t'
441 system_key = 'system\t'
442 global_key = 'global\t'
443 local_key = 'local\t'
444 worktree_key = 'worktree\t'
445 command_scope, command_key = _append_tab('command')
446 command_line = 'command line:'
447 file_scheme = 'file:'
449 current_value = ''
450 current_key = ''
451 current_scope = ''
452 current_path = ''
454 for line in config_output.splitlines():
455 if not line:
456 continue
457 # pylint: disable=too-many-boolean-expressions
458 if (
459 line.startswith(system_key)
460 or line.startswith(global_key)
461 or line.startswith(local_key)
462 or line.startswith(command_key)
463 or line.startswith(worktree_key) # worktree and unknown are uncommon.
464 or line.startswith(unknown_key)
466 continuation = False
467 current_scope, current_path, rest = line.split('\t', 2)
468 if current_scope == command_scope:
469 continue
470 current_key, current_value = _config_key_value(rest, '=')
471 if current_path.startswith(file_scheme):
472 cache_paths.add(current_path[len(file_scheme):])
473 elif current_path == command_line:
474 continue
475 renamed_keys[current_key.lower()] = current_key
476 else:
477 # Values are allowed to span multiple lines when \n is embedded
478 # in the value. Detect this and append to the previous value.
479 continuation = True
480 if current_value and isinstance(current_value, str):
481 current_value += '\n'
482 current_value += line
483 else:
484 current_value = line
486 yield current_scope, current_key, current_value, continuation
489 def python_to_git(value):
490 if isinstance(value, bool):
491 return 'true' if value else 'false'
492 if isinstance(value, int_types):
493 return ustr(value)
494 return value
497 def get_remotes(cfg):
498 """Get all of the configured git remotes"""
499 # Gather all of the remote.*.url entries.
500 prefix = len('remote.')
501 suffix = len('.url')
502 return sorted(key[prefix:-suffix] for key in cfg.find('remote.*.url'))