pylint: remove bad-option-value from the configuration
[git-cola.git] / cola / gitcfg.py
blob123fda7bd081e0e41669ade618898fe01e3296a3
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 . import resources
16 from .compat import int_types
17 from .compat import ustr
20 def create(context):
21 """Create GitConfig instances"""
22 return GitConfig(context)
25 def _cache_key_from_paths(paths):
26 """Return a stat cache from the given paths"""
27 if not paths:
28 return None
29 mtimes = []
30 for path in sorted(paths):
31 try:
32 mtimes.append(core.stat(path).st_mtime)
33 except OSError:
34 continue
35 if mtimes:
36 return mtimes
37 return None
40 def _config_to_python(v):
41 """Convert a Git config string into a Python value"""
42 if v in ('true', 'yes'):
43 v = True
44 elif v in ('false', 'no'):
45 v = False
46 else:
47 try:
48 v = int(v)
49 except ValueError:
50 pass
51 return v
54 def unhex(value):
55 """Convert a value (int or hex string) into bytes"""
56 if isinstance(value, int_types):
57 # If the value is an integer then it's a value that was converted
58 # by the config reader. Zero-pad it into a 6-digit hex number.
59 value = '%06d' % value
60 return unhexlify(core.encode(value.lstrip('#')))
63 def _config_key_value(line, splitchar):
64 """Split a config line into a (key, value) pair"""
65 try:
66 k, v = line.split(splitchar, 1)
67 except ValueError:
68 # the user has an empty entry in their git config,
69 # which Git interprets as meaning "true"
70 k = line
71 v = 'true'
72 return k, _config_to_python(v)
75 def _append_tab(value):
76 """Return a value and the same value with tab appended"""
77 return (value, value + '\t')
80 class GitConfig(QtCore.QObject):
81 """Encapsulate access to git-config values."""
83 user_config_changed = Signal(str, object)
84 repo_config_changed = Signal(str, object)
85 updated = Signal()
87 def __init__(self, context):
88 super(GitConfig, self).__init__()
89 self.context = context
90 self.git = context.git
91 self._system = {}
92 self._global = {}
93 self._global_or_system = {}
94 self._local = {}
95 self._all = {}
96 self._renamed_keys = {}
97 self._multi_values = collections.defaultdict(list)
98 self._cache_key = None
99 self._cache_paths = []
100 self._attr_cache = {}
101 self._binary_cache = {}
103 def reset(self):
104 self._cache_key = None
105 self._cache_paths = []
106 self._attr_cache.clear()
107 self._binary_cache.clear()
108 self.reset_values()
110 def reset_values(self):
111 self._system.clear()
112 self._global.clear()
113 self._global_or_system.clear()
114 self._local.clear()
115 self._all.clear()
116 self._renamed_keys.clear()
117 self._multi_values.clear()
119 def user(self):
120 return copy.deepcopy(self._global)
122 def repo(self):
123 return copy.deepcopy(self._local)
125 def all(self):
126 return copy.deepcopy(self._all)
128 def _is_cached(self):
130 Return True when the cache matches.
132 Updates the cache and returns False when the cache does not match.
135 cache_key = _cache_key_from_paths(self._cache_paths)
136 return self._cache_key and cache_key == self._cache_key
138 def update(self):
139 """Read git config value into the system, user and repo dicts."""
140 if self._is_cached():
141 return
143 self.reset_values()
145 show_scope = version.check_git(self.context, 'config-show-scope')
146 show_origin = version.check_git(self.context, 'config-show-origin')
147 if show_scope:
148 reader = _read_config_with_scope
149 elif show_origin:
150 reader = _read_config_with_origin
151 else:
152 reader = _read_config_fallback
154 unknown_scope = 'unknown'
155 system_scope = 'system'
156 global_scope = 'global'
157 local_scope = 'local'
158 worktree_scope = 'worktree'
159 cache_paths = set()
161 for (current_scope, current_key, current_value, continuation) in reader(
162 self.context, cache_paths, self._renamed_keys
164 # Store the values for fast cached lookup.
165 self._all[current_key] = current_value
167 # macOS has credential.helper=osxkeychain in the "unknown" scope from
168 # /Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig.
169 # Treat "unknown" as equivalent to "system" (lowest priority).
170 if current_scope in (system_scope, unknown_scope):
171 self._system[current_key] = current_value
172 self._global_or_system[current_key] = current_value
173 elif current_scope == global_scope:
174 self._global[current_key] = current_value
175 self._global_or_system[current_key] = current_value
176 # "worktree" is treated as equivalent to "local".
177 elif current_scope in (local_scope, worktree_scope):
178 self._local[current_key] = current_value
180 # Add this value to the multi-values storage used by get_all().
181 # This allows us to handle keys that store multiple values.
182 if continuation:
183 # If this is a continuation line then we should *not* append to its
184 # multi-values list. We should update it in-place.
185 self._multi_values[current_key][-1] = current_value
186 else:
187 self._multi_values[current_key].append(current_value)
189 # Update the cache
190 self._cache_paths = sorted(cache_paths)
191 self._cache_key = _cache_key_from_paths(self._cache_paths)
193 # Send a notification that the configuration has been updated.
194 self.updated.emit()
196 def _get(self, src, key, default, fn=None, cached=True):
197 if not cached or not src:
198 self.update()
199 try:
200 value = self._get_value(src, key)
201 except KeyError:
202 if fn:
203 value = fn()
204 else:
205 value = default
206 return value
208 def _get_value(self, src, key):
209 """Return a value from the map"""
210 try:
211 return src[key]
212 except KeyError:
213 pass
214 # Try the original key name.
215 key = self._renamed_keys.get(key.lower(), key)
216 try:
217 return src[key]
218 except KeyError:
219 pass
220 # Allow the final KeyError to bubble up
221 return src[key.lower()]
223 def get(self, key, default=None, fn=None, cached=True):
224 """Return the string value for a config key."""
225 return self._get(self._all, key, default, fn=fn, cached=cached)
227 def get_all(self, key):
228 """Return all values for a key sorted in priority order
230 The purpose of this function is to group the values returned by
231 `git config --show-origin --list` so that the relative order is
232 preserved and can be overridden at each level.
234 One use case is the `cola.icontheme` variable, which is an ordered
235 list of icon themes to load. This value can be set both in
236 ~/.gitconfig as well as .git/config, and we want to allow a
237 relative order to be defined in either file.
239 The problem is that git will read the system /etc/gitconfig,
240 global ~/.gitconfig, and then the local .git/config settings
241 and return them in that order, so we must post-process them to
242 get them in an order which makes sense for use for our values.
243 Otherwise, we cannot replace the order, or make a specific theme used
244 first, in our local .git/config since the native order returned by
245 git will always list the global config before the local one.
247 get_all() allows for this use case by reading from a defaultdict
248 that contains all of the per-config values separately so that the
249 caller can order them according to its preferred precedence.
251 if not self._multi_values:
252 self.update()
253 # Check for this key as-is.
254 if key in self._multi_values:
255 return self._multi_values[key]
257 # Check for a renamed version of this key (x.kittycat -> x.kittyCat)
258 renamed_key = self._renamed_keys.get(key.lower(), key)
259 if renamed_key in self._multi_values:
260 return self._multi_values[renamed_key]
262 key_lower = key.lower()
263 if key_lower in self._multi_values:
264 return self._multi_values[key_lower]
265 # Nothing found -> empty list.
266 return []
268 def get_user(self, key, default=None):
269 return self._get(self._global, key, default)
271 def get_repo(self, key, default=None):
272 return self._get(self._local, key, default)
274 def get_user_or_system(self, key, default=None):
275 return self._get(self._global_or_system, key, default)
277 def set_user(self, key, value):
278 if value in (None, ''):
279 self.git.config('--global', key, unset=True, _readonly=True)
280 else:
281 self.git.config('--global', key, python_to_git(value), _readonly=True)
282 self.update()
283 self.user_config_changed.emit(key, value)
285 def set_repo(self, key, value):
286 if value in (None, ''):
287 self.git.config(key, unset=True, _readonly=True)
288 else:
289 self.git.config(key, python_to_git(value), _readonly=True)
290 self.update()
291 self.repo_config_changed.emit(key, value)
293 def find(self, pat):
294 """Return a a dict of values for all keys matching the specified pattern"""
295 pat = pat.lower()
296 match = fnmatch.fnmatch
297 result = {}
298 if not self._all:
299 self.update()
300 for key, val in self._all.items():
301 if match(key.lower(), pat):
302 result[key] = val
303 return result
305 def is_annex(self):
306 """Return True when git-annex is enabled"""
307 return bool(self.get('annex.uuid', default=False))
309 def gui_encoding(self):
310 return self.get('gui.encoding', default=None)
312 def is_per_file_attrs_enabled(self):
313 return self.get(
314 'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes')
317 def is_binary(self, path):
318 """Return True if the file has the binary attribute set"""
319 if not self.is_per_file_attrs_enabled():
320 return None
321 cache = self._binary_cache
322 try:
323 value = cache[path]
324 except KeyError:
325 value = cache[path] = self._is_binary(path)
326 return value
328 def _is_binary(self, path):
329 """Return the file encoding for a path"""
330 value = self.check_attr('binary', path)
331 return value == 'set'
333 def file_encoding(self, path):
334 if not self.is_per_file_attrs_enabled():
335 return self.gui_encoding()
336 cache = self._attr_cache
337 try:
338 value = cache[path]
339 except KeyError:
340 value = cache[path] = self._file_encoding(path) or self.gui_encoding()
341 return value
343 def _file_encoding(self, path):
344 """Return the file encoding for a path"""
345 encoding = self.check_attr('encoding', path)
346 if encoding in ('unspecified', 'unset', 'set'):
347 result = None
348 else:
349 result = encoding
350 return result
352 def check_attr(self, attr, path):
353 """Check file attributes for a path"""
354 value = None
355 status, out, _ = self.git.check_attr(attr, '--', path, _readonly=True)
356 if status == 0:
357 header = '%s: %s: ' % (path, attr)
358 if out.startswith(header):
359 value = out[len(header) :].strip()
360 return value
362 def get_guitool_opts(self, name):
363 """Return the guitool.<name> namespace as a dict
365 The dict keys are simplified so that "guitool.$name.cmd" is accessible
366 as `opts[cmd]`.
369 prefix = len('guitool.%s.' % name)
370 guitools = self.find('guitool.%s.*' % name)
371 return {key[prefix:]: value for (key, value) in guitools.items()}
373 def get_guitool_names(self):
374 guitools = self.find('guitool.*.cmd')
375 prefix = len('guitool.')
376 suffix = len('.cmd')
377 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
379 def get_guitool_names_and_shortcuts(self):
380 """Return guitool names and their configured shortcut"""
381 names = self.get_guitool_names()
382 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
384 def terminal(self):
385 """Return a suitable terminal command for running a shell"""
386 term = self.get('cola.terminal', default=None)
387 if term:
388 return term
390 # find a suitable default terminal
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
404 # If no terminal has been configured then we'll look for the following programs
405 # and use the first one we find.
406 terminals = (
407 # (<executable>, <command> for running arbitrary commands)
408 ('kitty', 'kitty'),
409 ('alacritty', 'alacritty -e'),
410 ('uxterm', 'uxterm -e'),
411 ('konsole', 'konsole -e'),
412 ('gnome-terminal', 'gnome-terminal --'),
413 ('mate-terminal', 'mate-terminal --'),
414 ('xterm', 'xterm -e'),
416 for executable, command in terminals:
417 if core.find_executable(executable):
418 return command
419 return None
421 def color(self, key, default):
422 value = self.get('cola.color.%s' % key, default=default)
423 struct_layout = core.encode('BBB')
424 try:
425 # pylint: disable=no-member
426 r, g, b = struct.unpack(struct_layout, unhex(value))
427 except (struct.error, TypeError):
428 # pylint: disable=no-member
429 r, g, b = struct.unpack(struct_layout, unhex(default))
430 return (r, g, b)
432 def hooks(self):
433 """Return the path to the git hooks directory"""
434 gitdir_hooks = self.git.git_path('hooks')
435 return self.get('core.hookspath', default=gitdir_hooks)
437 def hooks_path(self, *paths):
438 """Return a path from within the git hooks directory"""
439 return os.path.join(self.hooks(), *paths)
442 def _read_config_with_scope(context, cache_paths, renamed_keys):
443 """Read the output from "git config --show-scope --show-origin --list
445 ``--show-scope`` was introduced in Git v2.26.0.
447 unknown_key = 'unknown\t'
448 system_key = 'system\t'
449 global_key = 'global\t'
450 local_key = 'local\t'
451 worktree_key = 'worktree\t'
452 command_scope, command_key = _append_tab('command')
453 command_line = 'command line:'
454 file_scheme = 'file:'
456 current_value = ''
457 current_key = ''
458 current_scope = ''
459 current_path = ''
461 status, config_output, _ = context.git.config(
462 show_origin=True, show_scope=True, list=True, includes=True
464 if status != 0:
465 return
467 for line in config_output.splitlines():
468 if not line:
469 continue
470 # pylint: disable=too-many-boolean-expressions
471 if (
472 line.startswith(system_key)
473 or line.startswith(global_key)
474 or line.startswith(local_key)
475 or line.startswith(command_key)
476 or line.startswith(worktree_key) # worktree and unknown are uncommon.
477 or line.startswith(unknown_key)
479 continuation = False
480 current_scope, current_path, rest = line.split('\t', 2)
481 if current_scope == command_scope:
482 continue
483 current_key, current_value = _config_key_value(rest, '=')
484 if current_path.startswith(file_scheme):
485 cache_paths.add(current_path[len(file_scheme) :])
486 elif current_path == command_line:
487 continue
488 renamed_keys[current_key.lower()] = current_key
489 else:
490 # Values are allowed to span multiple lines when \n is embedded
491 # in the value. Detect this and append to the previous value.
492 continuation = True
493 if current_value and isinstance(current_value, str):
494 current_value += '\n'
495 current_value += line
496 else:
497 current_value = line
499 yield current_scope, current_key, current_value, continuation
502 def _read_config_with_origin(context, cache_paths, renamed_keys):
503 """Read the output from "git config --show-origin --list
505 ``--show-origin`` was introduced in Git v2.8.0.
507 command_line = 'command line:\t'
508 system_scope = 'system'
509 global_scope = 'global'
510 local_scope = 'local'
511 file_scheme = 'file:'
513 system_scope_id = 0
514 global_scope_id = 1
515 local_scope_id = 2
517 current_value = ''
518 current_key = ''
519 current_path = ''
520 current_scope = system_scope
521 current_scope_id = system_scope_id
523 status, config_output, _ = context.git.config(
524 show_origin=True, list=True, includes=True
526 if status != 0:
527 return
529 for line in config_output.splitlines():
530 if not line or line.startswith(command_line):
531 continue
532 try:
533 tab_index = line.index('\t')
534 except ValueError:
535 tab_index = 0
536 if line.startswith(file_scheme) and tab_index > 5:
537 continuation = False
538 current_path = line[:tab_index]
539 rest = line[tab_index + 1 :]
541 cache_paths.add(current_path)
542 current_key, current_value = _config_key_value(rest, '=')
543 renamed_keys[current_key.lower()] = current_key
545 # The valid state machine transitions are system -> global,
546 # system -> local and global -> local. We start from the system state.
547 basename = os.path.basename(current_path)
548 if current_scope_id == system_scope_id and basename == '.gitconfig':
549 # system -> global
550 current_scope_id = global_scope_id
551 current_scope = global_scope
552 elif current_scope_id < local_scope_id and basename == 'config':
553 # system -> local, global -> local
554 current_scope_id = local_scope_id
555 current_scope = local_scope
556 else:
557 # Values are allowed to span multiple lines when \n is embedded
558 # in the value. Detect this and append to the previous value.
559 continuation = True
560 if current_value and isinstance(current_value, str):
561 current_value += '\n'
562 current_value += line
563 else:
564 current_value = line
566 yield current_scope, current_key, current_value, continuation
569 def _read_config_fallback(context, cache_paths, renamed_keys):
570 """Fallback config reader for Git < 2.8.0"""
571 system_scope = 'system'
572 global_scope = 'global'
573 local_scope = 'local'
574 includes = version.check_git(context, 'config-includes')
576 current_path = '/etc/gitconfig'
577 if os.path.exists(current_path):
578 cache_paths.add(current_path)
579 status, config_output, _ = context.git.config(
580 z=True,
581 list=True,
582 includes=includes,
583 system=True,
585 if status == 0:
586 for key, value in _read_config_from_null_list(config_output):
587 renamed_keys[key.lower()] = key
588 yield system_scope, key, value, False
590 gitconfig_home = core.expanduser(os.path.join('~', '.gitconfig'))
591 gitconfig_xdg = resources.xdg_config_home('git', 'config')
593 if os.path.exists(gitconfig_home):
594 gitconfig = gitconfig_home
595 elif os.path.exists(gitconfig_xdg):
596 gitconfig = gitconfig_xdg
597 else:
598 gitconfig = None
600 if gitconfig:
601 cache_paths.add(gitconfig)
602 status, config_output, _ = context.git.config(
603 z=True, list=True, includes=includes, **{'global': True}
605 if status == 0:
606 for key, value in _read_config_from_null_list(config_output):
607 renamed_keys[key.lower()] = key
608 yield global_scope, key, value, False
610 local_config = context.git.git_path('config')
611 if os.path.exists(local_config):
612 cache_paths.add(gitconfig)
613 status, config_output, _ = context.git.config(
614 z=True,
615 list=True,
616 includes=includes,
617 local=True,
619 if status == 0:
620 for key, value in _read_config_from_null_list(config_output):
621 renamed_keys[key.lower()] = key
622 yield local_scope, key, value, False
625 def _read_config_from_null_list(config_output):
626 """Parse the "git config --list -z" records"""
627 for record in config_output.rstrip('\0').split('\0'):
628 try:
629 name, value = record.split('\n', 1)
630 except ValueError:
631 name = record
632 value = 'true'
633 yield (name, _config_to_python(value))
636 def python_to_git(value):
637 if isinstance(value, bool):
638 return 'true' if value else 'false'
639 if isinstance(value, int_types):
640 return ustr(value)
641 return value
644 def get_remotes(cfg):
645 """Get all of the configured git remotes"""
646 # Gather all of the remote.*.url entries.
647 prefix = len('remote.')
648 suffix = len('.url')
649 return sorted(key[prefix:-suffix] for key in cfg.find('remote.*.url'))