1 from __future__
import absolute_import
, division
, print_function
, unicode_literals
2 from binascii
import unhexlify
9 from qtpy
import QtCore
10 from qtpy
.QtCore
import Signal
15 from . import resources
16 from .compat
import int_types
17 from .compat
import ustr
21 """Create GitConfig instances"""
22 return GitConfig(context
)
25 def _cache_key_from_paths(paths
):
26 """Return a stat cache from the given paths"""
30 for path
in sorted(paths
):
32 mtimes
.append(core
.stat(path
).st_mtime
)
40 def _config_to_python(value
):
41 """Convert a Git config string into a Python value"""
42 if value
in ('true', 'yes'):
44 elif value
in ('false', 'no'):
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"""
66 k
, v
= line
.split(splitchar
, 1)
68 # the user has an empty entry in their git config,
69 # which Git interprets as meaning "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)
87 def __init__(self
, context
):
88 super(GitConfig
, self
).__init
__()
89 self
.context
= context
90 self
.git
= context
.git
93 self
._global
_or
_system
= {}
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
= {}
104 self
._cache
_key
= None
105 self
._cache
_paths
= []
106 self
._attr
_cache
.clear()
107 self
._binary
_cache
.clear()
110 def reset_values(self
):
113 self
._global
_or
_system
.clear()
116 self
._renamed
_keys
.clear()
117 self
._multi
_values
.clear()
120 return copy
.deepcopy(self
._global
)
123 return copy
.deepcopy(self
._local
)
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
139 """Read git config value into the system, user and repo dicts."""
140 if self
._is
_cached
():
145 show_scope
= version
.check_git(self
.context
, 'config-show-scope')
146 show_origin
= version
.check_git(self
.context
, 'config-show-origin')
148 reader
= _read_config_with_scope
150 reader
= _read_config_with_origin
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'
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.
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
187 self
._multi
_values
[current_key
].append(current_value
)
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.
196 def _get(self
, src
, key
, default
, func
=None, cached
=True):
197 if not cached
or not src
:
200 value
= self
._get
_value
(src
, key
)
208 def _get_value(self
, src
, key
):
209 """Return a value from the map"""
214 # Try the original key name.
215 key
= self
._renamed
_keys
.get(key
.lower(), key
)
220 # Allow the final KeyError to bubble up
221 return src
[key
.lower()]
223 def get(self
, key
, default
=None, func
=None, cached
=True):
224 """Return the string value for a config key."""
225 return self
._get
(self
._all
, key
, default
, func
=func
, 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
:
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.
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)
281 self
.git
.config('--global', key
, python_to_git(value
), _readonly
=True)
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)
289 self
.git
.config(key
, python_to_git(value
), _readonly
=True)
291 self
.repo_config_changed
.emit(key
, value
)
294 """Return a a dict of values for all keys matching the specified pattern"""
296 match
= fnmatch
.fnmatch
300 for key
, val
in self
._all
.items():
301 if match(key
.lower(), pat
):
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
):
314 'cola.fileattributes', func
=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():
321 cache
= self
._binary
_cache
325 value
= cache
[path
] = self
._is
_binary
(path
)
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
340 value
= cache
[path
] = self
._file
_encoding
(path
) or self
.gui_encoding()
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'):
352 def check_attr(self
, attr
, path
):
353 """Check file attributes for a path"""
355 status
, out
, _
= self
.git
.check_attr(attr
, '--', path
, _readonly
=True)
357 header
= '%s: %s: ' % (path
, attr
)
358 if out
.startswith(header
):
359 value
= out
[len(header
) :].strip()
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
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.')
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
]
385 """Return a suitable terminal command for running a shell"""
386 term
= self
.get('cola.terminal', default
=None)
390 # find a suitable default terminal
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
):
404 # If no terminal has been configured then we'll look for the following programs
405 # and use the first one we find.
407 # (<executable>, <command> for running arbitrary commands)
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
):
421 def color(self
, key
, default
):
422 value
= self
.get('cola.color.%s' % key
, default
=default
)
423 struct_layout
= core
.encode('BBB')
425 # pylint: disable=no-member
426 red
, green
, blue
= struct
.unpack(struct_layout
, unhex(value
))
427 except (struct
.error
, TypeError):
428 # pylint: disable=no-member
429 red
, green
, blue
= struct
.unpack(struct_layout
, unhex(default
))
430 return (red
, green
, blue
)
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:'
461 status
, config_output
, _
= context
.git
.config(
462 show_origin
=True, show_scope
=True, list=True, includes
=True
467 for line
in config_output
.splitlines():
470 # pylint: disable=too-many-boolean-expressions
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
)
480 current_scope
, current_path
, rest
= line
.split('\t', 2)
481 if current_scope
== command_scope
:
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
:
488 renamed_keys
[current_key
.lower()] = current_key
490 # Values are allowed to span multiple lines when \n is embedded
491 # in the value. Detect this and append to the previous value.
493 if current_value
and isinstance(current_value
, str):
494 current_value
+= '\n'
495 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:'
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
529 for line
in config_output
.splitlines():
530 if not line
or line
.startswith(command_line
):
533 tab_index
= line
.index('\t')
536 if line
.startswith(file_scheme
) and tab_index
> 5:
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':
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
557 # Values are allowed to span multiple lines when \n is embedded
558 # in the value. Detect this and append to the previous value.
560 if current_value
and isinstance(current_value
, str):
561 current_value
+= '\n'
562 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(
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
601 cache_paths
.add(gitconfig
)
602 status
, config_output
, _
= context
.git
.config(
603 z
=True, list=True, includes
=includes
, **{'global': True}
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(
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'):
629 name
, value
= record
.split('\n', 1)
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
):
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.')
649 return sorted(key
[prefix
:-suffix
] for key
in cfg
.find('remote.*.url'))