Merge pull request #1391 from davvid/macos/hotkeys
[git-cola.git] / cola / gitcfg.py
blob1e247181815d3dcf461bd34fc3c354ac4dac47ea
1 from binascii import unhexlify
2 import collections
3 import copy
4 import fnmatch
5 import os
6 import struct
8 try:
9 import pwd
11 _use_pwd = True
12 except ImportError:
13 _use_pwd = False
16 from qtpy import QtCore
17 from qtpy.QtCore import Signal
19 from . import core
20 from . import utils
21 from . import version
22 from . import resources
23 from .compat import int_types
24 from .compat import ustr
27 def create(context):
28 """Create GitConfig instances"""
29 return GitConfig(context)
32 def _cache_key_from_paths(paths):
33 """Return a stat cache from the given paths"""
34 if not paths:
35 return None
36 mtimes = []
37 for path in sorted(paths):
38 try:
39 mtimes.append(core.stat(path).st_mtime)
40 except OSError:
41 continue
42 if mtimes:
43 return mtimes
44 return None
47 def _config_to_python(value):
48 """Convert a Git config string into a Python value"""
49 if value in ('true', 'yes'):
50 value = True
51 elif value in ('false', 'no'):
52 value = False
53 else:
54 try:
55 value = int(value)
56 except ValueError:
57 pass
58 return value
61 def unhex(value):
62 """Convert a value (int or hex string) into bytes"""
63 if isinstance(value, int_types):
64 # If the value is an integer then it's a value that was converted
65 # by the config reader. Zero-pad it into a 6-digit hex number.
66 value = '%06d' % value
67 return unhexlify(core.encode(value.lstrip('#')))
70 def _config_key_value(line, splitchar):
71 """Split a config line into a (key, value) pair"""
72 try:
73 k, v = line.split(splitchar, 1)
74 except ValueError:
75 # the user has an empty entry in their git config,
76 # which Git interprets as meaning "true"
77 k = line
78 v = 'true'
79 return k, _config_to_python(v)
82 def _append_tab(value):
83 """Return a value and the same value with tab appended"""
84 return (value, value + '\t')
87 class GitConfig(QtCore.QObject):
88 """Encapsulate access to git-config values."""
90 user_config_changed = Signal(str, object)
91 repo_config_changed = Signal(str, object)
92 updated = Signal()
94 def __init__(self, context):
95 super().__init__()
96 self.context = context
97 self.git = context.git
98 self._system = {}
99 self._global = {}
100 self._global_or_system = {}
101 self._local = {}
102 self._all = {}
103 self._renamed_keys = {}
104 self._multi_values = collections.defaultdict(list)
105 self._cache_key = None
106 self._cache_paths = []
107 self._attr_cache = {}
108 self._binary_cache = {}
110 def reset(self):
111 self._cache_key = None
112 self._cache_paths = []
113 self._attr_cache.clear()
114 self._binary_cache.clear()
115 self.reset_values()
117 def reset_values(self):
118 self._system.clear()
119 self._global.clear()
120 self._global_or_system.clear()
121 self._local.clear()
122 self._all.clear()
123 self._renamed_keys.clear()
124 self._multi_values.clear()
126 def user(self):
127 return copy.deepcopy(self._global)
129 def repo(self):
130 return copy.deepcopy(self._local)
132 def all(self):
133 return copy.deepcopy(self._all)
135 def _is_cached(self):
137 Return True when the cache matches.
139 Updates the cache and returns False when the cache does not match.
142 cache_key = _cache_key_from_paths(self._cache_paths)
143 return self._cache_key and cache_key == self._cache_key
145 def update(self):
146 """Read git config value into the system, user and repo dicts."""
147 if self._is_cached():
148 return
150 self.reset_values()
152 show_scope = version.check_git(self.context, 'config-show-scope')
153 show_origin = version.check_git(self.context, 'config-show-origin')
154 if show_scope:
155 reader = _read_config_with_scope
156 elif show_origin:
157 reader = _read_config_with_origin
158 else:
159 reader = _read_config_fallback
161 unknown_scope = 'unknown'
162 system_scope = 'system'
163 global_scope = 'global'
164 local_scope = 'local'
165 worktree_scope = 'worktree'
166 cache_paths = set()
168 for current_scope, current_key, current_value, continuation in reader(
169 self.context, cache_paths, self._renamed_keys
171 # Store the values for fast cached lookup.
172 self._all[current_key] = current_value
174 # macOS has credential.helper=osxkeychain in the "unknown" scope from
175 # /Applications/Xcode.app/Contents/Developer/usr/share/git-core/gitconfig.
176 # Treat "unknown" as equivalent to "system" (lowest priority).
177 if current_scope in (system_scope, unknown_scope):
178 self._system[current_key] = current_value
179 self._global_or_system[current_key] = current_value
180 elif current_scope == global_scope:
181 self._global[current_key] = current_value
182 self._global_or_system[current_key] = current_value
183 # "worktree" is treated as equivalent to "local".
184 elif current_scope in (local_scope, worktree_scope):
185 self._local[current_key] = current_value
187 # Add this value to the multi-values storage used by get_all().
188 # This allows us to handle keys that store multiple values.
189 if continuation:
190 # If this is a continuation line then we should *not* append to its
191 # multi-values list. We should update it in-place.
192 self._multi_values[current_key][-1] = current_value
193 else:
194 self._multi_values[current_key].append(current_value)
196 # Update the cache
197 self._cache_paths = sorted(cache_paths)
198 self._cache_key = _cache_key_from_paths(self._cache_paths)
200 # Send a notification that the configuration has been updated.
201 self.updated.emit()
203 def _get(self, src, key, default, func=None, cached=True):
204 if not cached or not src:
205 self.update()
206 try:
207 value = self._get_value(src, key)
208 except KeyError:
209 if func:
210 value = func()
211 else:
212 value = default
213 return value
215 def _get_value(self, src, key):
216 """Return a value from the map"""
217 try:
218 return src[key]
219 except KeyError:
220 pass
221 # Try the original key name.
222 key = self._renamed_keys.get(key.lower(), key)
223 try:
224 return src[key]
225 except KeyError:
226 pass
227 # Allow the final KeyError to bubble up
228 return src[key.lower()]
230 def get(self, key, default=None, func=None, cached=True):
231 """Return the string value for a config key."""
232 return self._get(self._all, key, default, func=func, cached=cached)
234 def get_all(self, key):
235 """Return all values for a key sorted in priority order
237 The purpose of this function is to group the values returned by
238 `git config --show-origin --list` so that the relative order is
239 preserved and can be overridden at each level.
241 One use case is the `cola.icontheme` variable, which is an ordered
242 list of icon themes to load. This value can be set both in
243 ~/.gitconfig as well as .git/config, and we want to allow a
244 relative order to be defined in either file.
246 The problem is that git will read the system /etc/gitconfig,
247 global ~/.gitconfig, and then the local .git/config settings
248 and return them in that order, so we must post-process them to
249 get them in an order which makes sense for use for our values.
250 Otherwise, we cannot replace the order, or make a specific theme used
251 first, in our local .git/config since the native order returned by
252 git will always list the global config before the local one.
254 get_all() allows for this use case by reading from a defaultdict
255 that contains all of the per-config values separately so that the
256 caller can order them according to its preferred precedence.
258 if not self._multi_values:
259 self.update()
260 # Check for this key as-is.
261 if key in self._multi_values:
262 return self._multi_values[key]
264 # Check for a renamed version of this key (x.kittycat -> x.kittyCat)
265 renamed_key = self._renamed_keys.get(key.lower(), key)
266 if renamed_key in self._multi_values:
267 return self._multi_values[renamed_key]
269 key_lower = key.lower()
270 if key_lower in self._multi_values:
271 return self._multi_values[key_lower]
272 # Nothing found -> empty list.
273 return []
275 def get_user(self, key, default=None):
276 return self._get(self._global, key, default)
278 def get_repo(self, key, default=None):
279 return self._get(self._local, key, default)
281 def get_user_or_system(self, key, default=None):
282 return self._get(self._global_or_system, key, default)
284 def set_user(self, key, value):
285 if value in (None, ''):
286 self.git.config('--global', key, unset=True, _readonly=True)
287 else:
288 self.git.config('--global', key, python_to_git(value), _readonly=True)
289 self.update()
290 self.user_config_changed.emit(key, value)
292 def set_repo(self, key, value):
293 if value in (None, ''):
294 self.git.config(key, unset=True, _readonly=True)
295 else:
296 self.git.config(key, python_to_git(value), _readonly=True)
297 self.update()
298 self.repo_config_changed.emit(key, value)
300 def find(self, pat):
301 """Return a dict of values for all keys matching the specified pattern"""
302 pat = pat.lower()
303 match = fnmatch.fnmatch
304 result = {}
305 if not self._all:
306 self.update()
307 for key, val in self._all.items():
308 if match(key.lower(), pat):
309 result[key] = val
310 return result
312 def is_annex(self):
313 """Return True when git-annex is enabled"""
314 return bool(self.get('annex.uuid', default=False))
316 def gui_encoding(self):
317 return self.get('gui.encoding', default=None)
319 def is_per_file_attrs_enabled(self):
320 return self.get(
321 'cola.fileattributes', func=lambda: os.path.exists('.gitattributes')
324 def is_binary(self, path):
325 """Return True if the file has the binary attribute set"""
326 if not self.is_per_file_attrs_enabled():
327 return None
328 cache = self._binary_cache
329 try:
330 value = cache[path]
331 except KeyError:
332 value = cache[path] = self._is_binary(path)
333 return value
335 def _is_binary(self, path):
336 """Return the file encoding for a path"""
337 value = self.check_attr('binary', path)
338 return value == 'set'
340 def file_encoding(self, path):
341 if not self.is_per_file_attrs_enabled():
342 return self.gui_encoding()
343 cache = self._attr_cache
344 try:
345 value = cache[path]
346 except KeyError:
347 value = cache[path] = self._file_encoding(path) or self.gui_encoding()
348 return value
350 def _file_encoding(self, path):
351 """Return the file encoding for a path"""
352 encoding = self.check_attr('encoding', path)
353 if encoding in ('unspecified', 'unset', 'set'):
354 result = None
355 else:
356 result = encoding
357 return result
359 def check_attr(self, attr, path):
360 """Check file attributes for a path"""
361 value = None
362 status, out, _ = self.git.check_attr(attr, '--', path, _readonly=True)
363 if status == 0:
364 header = f'{path}: {attr}: '
365 if out.startswith(header):
366 value = out[len(header) :].strip()
367 return value
369 def get_author(self):
370 """Return (name, email) for authoring commits"""
371 if _use_pwd:
372 user = pwd.getpwuid(os.getuid()).pw_name
373 else:
374 user = os.getenv('USER', 'unknown')
376 name = self.get('user.name', user)
377 email = self.get('user.email', f'{user}@{core.node()}')
378 return (name, email)
380 def get_guitool_opts(self, name):
381 """Return the guitool.<name> namespace as a dict
383 The dict keys are simplified so that "guitool.$name.cmd" is accessible
384 as `opts[cmd]`.
387 prefix = len('guitool.%s.' % name)
388 guitools = self.find('guitool.%s.*' % name)
389 return {key[prefix:]: value for (key, value) in guitools.items()}
391 def get_guitool_names(self):
392 guitools = self.find('guitool.*.cmd')
393 prefix = len('guitool.')
394 suffix = len('.cmd')
395 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()])
397 def get_guitool_names_and_shortcuts(self):
398 """Return guitool names and their configured shortcut"""
399 names = self.get_guitool_names()
400 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names]
402 def terminal(self):
403 """Return a suitable terminal command for running a shell"""
404 term = self.get('cola.terminal', default=None)
405 if term:
406 return term
408 # find a suitable default terminal
409 if utils.is_win32():
410 # Try to find Git's sh.exe directory in
411 # one of the typical locations
412 pf = os.environ.get('ProgramFiles', r'C:\Program Files')
413 pf32 = os.environ.get('ProgramFiles(x86)', r'C:\Program Files (x86)')
414 pf64 = os.environ.get('ProgramW6432', r'C:\Program Files')
416 for p in [pf64, pf32, pf, 'C:\\']:
417 candidate = os.path.join(p, r'Git\bin\sh.exe')
418 if os.path.isfile(candidate):
419 return candidate
420 return None
422 # If no terminal has been configured then we'll look for the following programs
423 # and use the first one we find.
424 terminals = (
425 # (<executable>, <command> for running arbitrary commands)
426 ('kitty', 'kitty'),
427 ('alacritty', 'alacritty -e'),
428 ('uxterm', 'uxterm -e'),
429 ('konsole', 'konsole -e'),
430 ('gnome-terminal', 'gnome-terminal --'),
431 ('mate-terminal', 'mate-terminal --'),
432 ('xterm', 'xterm -e'),
434 for executable, command in terminals:
435 if core.find_executable(executable):
436 return command
437 return None
439 def color(self, key, default):
440 value = self.get('cola.color.%s' % key, default=default)
441 struct_layout = core.encode('BBB')
442 try:
443 red, green, blue = struct.unpack(struct_layout, unhex(value))
444 except (struct.error, TypeError):
445 red, green, blue = struct.unpack(struct_layout, unhex(default))
446 return (red, green, blue)
448 def hooks(self):
449 """Return the path to the git hooks directory"""
450 gitdir_hooks = self.git.git_path('hooks')
451 return self.get('core.hookspath', default=gitdir_hooks)
453 def hooks_path(self, *paths):
454 """Return a path from within the git hooks directory"""
455 return os.path.join(self.hooks(), *paths)
458 def _read_config_with_scope(context, cache_paths, renamed_keys):
459 """Read the output from "git config --show-scope --show-origin --list
461 ``--show-scope`` was introduced in Git v2.26.0.
463 unknown_key = 'unknown\t'
464 system_key = 'system\t'
465 global_key = 'global\t'
466 local_key = 'local\t'
467 worktree_key = 'worktree\t'
468 command_scope, command_key = _append_tab('command')
469 command_line = 'command line:'
470 file_scheme = 'file:'
472 current_value = ''
473 current_key = ''
474 current_scope = ''
475 current_path = ''
477 status, config_output, _ = context.git.config(
478 show_origin=True, show_scope=True, list=True, includes=True
480 if status != 0:
481 return
483 for line in config_output.splitlines():
484 if not line:
485 continue
486 if (
487 line.startswith(system_key)
488 or line.startswith(global_key)
489 or line.startswith(local_key)
490 or line.startswith(command_key)
491 or line.startswith(worktree_key) # worktree and unknown are uncommon.
492 or line.startswith(unknown_key)
494 continuation = False
495 current_scope, current_path, rest = line.split('\t', 2)
496 if current_scope == command_scope:
497 continue
498 current_key, current_value = _config_key_value(rest, '=')
499 if current_path.startswith(file_scheme):
500 cache_paths.add(current_path[len(file_scheme) :])
501 elif current_path == command_line:
502 continue
503 renamed_keys[current_key.lower()] = current_key
504 else:
505 # Values are allowed to span multiple lines when \n is embedded
506 # in the value. Detect this and append to the previous value.
507 continuation = True
508 if current_value and isinstance(current_value, str):
509 current_value += '\n'
510 current_value += line
511 else:
512 current_value = line
514 yield current_scope, current_key, current_value, continuation
517 def _read_config_with_origin(context, cache_paths, renamed_keys):
518 """Read the output from "git config --show-origin --list
520 ``--show-origin`` was introduced in Git v2.8.0.
522 command_line = 'command line:\t'
523 system_scope = 'system'
524 global_scope = 'global'
525 local_scope = 'local'
526 file_scheme = 'file:'
528 system_scope_id = 0
529 global_scope_id = 1
530 local_scope_id = 2
532 current_value = ''
533 current_key = ''
534 current_path = ''
535 current_scope = system_scope
536 current_scope_id = system_scope_id
538 status, config_output, _ = context.git.config(
539 show_origin=True, list=True, includes=True
541 if status != 0:
542 return
544 for line in config_output.splitlines():
545 if not line or line.startswith(command_line):
546 continue
547 try:
548 tab_index = line.index('\t')
549 except ValueError:
550 tab_index = 0
551 if line.startswith(file_scheme) and tab_index > 5:
552 continuation = False
553 current_path = line[:tab_index]
554 rest = line[tab_index + 1 :]
556 cache_paths.add(current_path)
557 current_key, current_value = _config_key_value(rest, '=')
558 renamed_keys[current_key.lower()] = current_key
560 # The valid state machine transitions are system -> global,
561 # system -> local and global -> local. We start from the system state.
562 basename = os.path.basename(current_path)
563 if current_scope_id == system_scope_id and basename == '.gitconfig':
564 # system -> global
565 current_scope_id = global_scope_id
566 current_scope = global_scope
567 elif current_scope_id < local_scope_id and basename == 'config':
568 # system -> local, global -> local
569 current_scope_id = local_scope_id
570 current_scope = local_scope
571 else:
572 # Values are allowed to span multiple lines when \n is embedded
573 # in the value. Detect this and append to the previous value.
574 continuation = True
575 if current_value and isinstance(current_value, str):
576 current_value += '\n'
577 current_value += line
578 else:
579 current_value = line
581 yield current_scope, current_key, current_value, continuation
584 def _read_config_fallback(context, cache_paths, renamed_keys):
585 """Fallback config reader for Git < 2.8.0"""
586 system_scope = 'system'
587 global_scope = 'global'
588 local_scope = 'local'
589 includes = version.check_git(context, 'config-includes')
591 current_path = '/etc/gitconfig'
592 if os.path.exists(current_path):
593 cache_paths.add(current_path)
594 status, config_output, _ = context.git.config(
595 z=True,
596 list=True,
597 includes=includes,
598 system=True,
600 if status == 0:
601 for key, value in _read_config_from_null_list(config_output):
602 renamed_keys[key.lower()] = key
603 yield system_scope, key, value, False
605 gitconfig_home = core.expanduser(os.path.join('~', '.gitconfig'))
606 gitconfig_xdg = resources.xdg_config_home('git', 'config')
608 if os.path.exists(gitconfig_home):
609 gitconfig = gitconfig_home
610 elif os.path.exists(gitconfig_xdg):
611 gitconfig = gitconfig_xdg
612 else:
613 gitconfig = None
615 if gitconfig:
616 cache_paths.add(gitconfig)
617 status, config_output, _ = context.git.config(
618 z=True, list=True, includes=includes, **{'global': True}
620 if status == 0:
621 for key, value in _read_config_from_null_list(config_output):
622 renamed_keys[key.lower()] = key
623 yield global_scope, key, value, False
625 local_config = context.git.git_path('config')
626 if local_config and os.path.exists(local_config):
627 cache_paths.add(gitconfig)
628 status, config_output, _ = context.git.config(
629 z=True,
630 list=True,
631 includes=includes,
632 local=True,
634 if status == 0:
635 for key, value in _read_config_from_null_list(config_output):
636 renamed_keys[key.lower()] = key
637 yield local_scope, key, value, False
640 def _read_config_from_null_list(config_output):
641 """Parse the "git config --list -z" records"""
642 for record in config_output.rstrip('\0').split('\0'):
643 try:
644 name, value = record.split('\n', 1)
645 except ValueError:
646 name = record
647 value = 'true'
648 yield (name, _config_to_python(value))
651 def python_to_git(value):
652 if isinstance(value, bool):
653 return 'true' if value else 'false'
654 if isinstance(value, int_types):
655 return ustr(value)
656 return value
659 def get_remotes(cfg):
660 """Get all of the configured git remotes"""
661 # Gather all of the remote.*.url entries.
662 prefix = len('remote.')
663 suffix = len('.url')
664 return sorted(key[prefix:-suffix] for key in cfg.find('remote.*.url'))