1 from binascii
import unhexlify
16 from qtpy
import QtCore
17 from qtpy
.QtCore
import Signal
22 from . import resources
23 from .compat
import int_types
24 from .compat
import ustr
28 """Create GitConfig instances"""
29 return GitConfig(context
)
32 def _cache_key_from_paths(paths
):
33 """Return a stat cache from the given paths"""
37 for path
in sorted(paths
):
39 mtimes
.append(core
.stat(path
).st_mtime
)
47 def _config_to_python(value
):
48 """Convert a Git config string into a Python value"""
49 if value
in ('true', 'yes'):
51 elif value
in ('false', 'no'):
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"""
73 k
, v
= line
.split(splitchar
, 1)
75 # the user has an empty entry in their git config,
76 # which Git interprets as meaning "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)
94 def __init__(self
, context
):
96 self
.context
= context
97 self
.git
= context
.git
100 self
._global
_or
_system
= {}
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
= {}
111 self
._cache
_key
= None
112 self
._cache
_paths
= []
113 self
._attr
_cache
.clear()
114 self
._binary
_cache
.clear()
117 def reset_values(self
):
120 self
._global
_or
_system
.clear()
123 self
._renamed
_keys
.clear()
124 self
._multi
_values
.clear()
127 return copy
.deepcopy(self
._global
)
130 return copy
.deepcopy(self
._local
)
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
146 """Read git config value into the system, user and repo dicts."""
147 if self
._is
_cached
():
152 show_scope
= version
.check_git(self
.context
, 'config-show-scope')
153 show_origin
= version
.check_git(self
.context
, 'config-show-origin')
155 reader
= _read_config_with_scope
157 reader
= _read_config_with_origin
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'
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.
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
194 self
._multi
_values
[current_key
].append(current_value
)
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.
203 def _get(self
, src
, key
, default
, func
=None, cached
=True):
204 if not cached
or not src
:
207 value
= self
._get
_value
(src
, key
)
215 def _get_value(self
, src
, key
):
216 """Return a value from the map"""
221 # Try the original key name.
222 key
= self
._renamed
_keys
.get(key
.lower(), key
)
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
:
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.
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)
288 self
.git
.config('--global', key
, python_to_git(value
), _readonly
=True)
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)
296 self
.git
.config(key
, python_to_git(value
), _readonly
=True)
298 self
.repo_config_changed
.emit(key
, value
)
301 """Return a a dict of values for all keys matching the specified pattern"""
303 match
= fnmatch
.fnmatch
307 for key
, val
in self
._all
.items():
308 if match(key
.lower(), pat
):
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
):
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():
328 cache
= self
._binary
_cache
332 value
= cache
[path
] = self
._is
_binary
(path
)
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
347 value
= cache
[path
] = self
._file
_encoding
(path
) or self
.gui_encoding()
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'):
359 def check_attr(self
, attr
, path
):
360 """Check file attributes for a path"""
362 status
, out
, _
= self
.git
.check_attr(attr
, '--', path
, _readonly
=True)
364 header
= f
'{path}: {attr}: '
365 if out
.startswith(header
):
366 value
= out
[len(header
) :].strip()
369 def get_author(self
):
370 """Return (name, email) for authoring commits"""
372 user
= pwd
.getpwuid(os
.getuid()).pw_name
374 user
= os
.getenv('USER', 'unknown')
376 name
= self
.get('user.name', user
)
377 email
= self
.get('user.email', f
'{user}@{core.node()}')
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
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.')
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
]
403 """Return a suitable terminal command for running a shell"""
404 term
= self
.get('cola.terminal', default
=None)
408 # find a suitable default terminal
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
):
422 # If no terminal has been configured then we'll look for the following programs
423 # and use the first one we find.
425 # (<executable>, <command> for running arbitrary commands)
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
):
439 def color(self
, key
, default
):
440 value
= self
.get('cola.color.%s' % key
, default
=default
)
441 struct_layout
= core
.encode('BBB')
443 # pylint: disable=no-member
444 red
, green
, blue
= struct
.unpack(struct_layout
, unhex(value
))
445 except (struct
.error
, TypeError):
446 # pylint: disable=no-member
447 red
, green
, blue
= struct
.unpack(struct_layout
, unhex(default
))
448 return (red
, green
, blue
)
451 """Return the path to the git hooks directory"""
452 gitdir_hooks
= self
.git
.git_path('hooks')
453 return self
.get('core.hookspath', default
=gitdir_hooks
)
455 def hooks_path(self
, *paths
):
456 """Return a path from within the git hooks directory"""
457 return os
.path
.join(self
.hooks(), *paths
)
460 def _read_config_with_scope(context
, cache_paths
, renamed_keys
):
461 """Read the output from "git config --show-scope --show-origin --list
463 ``--show-scope`` was introduced in Git v2.26.0.
465 unknown_key
= 'unknown\t'
466 system_key
= 'system\t'
467 global_key
= 'global\t'
468 local_key
= 'local\t'
469 worktree_key
= 'worktree\t'
470 command_scope
, command_key
= _append_tab('command')
471 command_line
= 'command line:'
472 file_scheme
= 'file:'
479 status
, config_output
, _
= context
.git
.config(
480 show_origin
=True, show_scope
=True, list=True, includes
=True
485 for line
in config_output
.splitlines():
488 # pylint: disable=too-many-boolean-expressions
490 line
.startswith(system_key
)
491 or line
.startswith(global_key
)
492 or line
.startswith(local_key
)
493 or line
.startswith(command_key
)
494 or line
.startswith(worktree_key
) # worktree and unknown are uncommon.
495 or line
.startswith(unknown_key
)
498 current_scope
, current_path
, rest
= line
.split('\t', 2)
499 if current_scope
== command_scope
:
501 current_key
, current_value
= _config_key_value(rest
, '=')
502 if current_path
.startswith(file_scheme
):
503 cache_paths
.add(current_path
[len(file_scheme
) :])
504 elif current_path
== command_line
:
506 renamed_keys
[current_key
.lower()] = current_key
508 # Values are allowed to span multiple lines when \n is embedded
509 # in the value. Detect this and append to the previous value.
511 if current_value
and isinstance(current_value
, str):
512 current_value
+= '\n'
513 current_value
+= line
517 yield current_scope
, current_key
, current_value
, continuation
520 def _read_config_with_origin(context
, cache_paths
, renamed_keys
):
521 """Read the output from "git config --show-origin --list
523 ``--show-origin`` was introduced in Git v2.8.0.
525 command_line
= 'command line:\t'
526 system_scope
= 'system'
527 global_scope
= 'global'
528 local_scope
= 'local'
529 file_scheme
= 'file:'
538 current_scope
= system_scope
539 current_scope_id
= system_scope_id
541 status
, config_output
, _
= context
.git
.config(
542 show_origin
=True, list=True, includes
=True
547 for line
in config_output
.splitlines():
548 if not line
or line
.startswith(command_line
):
551 tab_index
= line
.index('\t')
554 if line
.startswith(file_scheme
) and tab_index
> 5:
556 current_path
= line
[:tab_index
]
557 rest
= line
[tab_index
+ 1 :]
559 cache_paths
.add(current_path
)
560 current_key
, current_value
= _config_key_value(rest
, '=')
561 renamed_keys
[current_key
.lower()] = current_key
563 # The valid state machine transitions are system -> global,
564 # system -> local and global -> local. We start from the system state.
565 basename
= os
.path
.basename(current_path
)
566 if current_scope_id
== system_scope_id
and basename
== '.gitconfig':
568 current_scope_id
= global_scope_id
569 current_scope
= global_scope
570 elif current_scope_id
< local_scope_id
and basename
== 'config':
571 # system -> local, global -> local
572 current_scope_id
= local_scope_id
573 current_scope
= local_scope
575 # Values are allowed to span multiple lines when \n is embedded
576 # in the value. Detect this and append to the previous value.
578 if current_value
and isinstance(current_value
, str):
579 current_value
+= '\n'
580 current_value
+= line
584 yield current_scope
, current_key
, current_value
, continuation
587 def _read_config_fallback(context
, cache_paths
, renamed_keys
):
588 """Fallback config reader for Git < 2.8.0"""
589 system_scope
= 'system'
590 global_scope
= 'global'
591 local_scope
= 'local'
592 includes
= version
.check_git(context
, 'config-includes')
594 current_path
= '/etc/gitconfig'
595 if os
.path
.exists(current_path
):
596 cache_paths
.add(current_path
)
597 status
, config_output
, _
= context
.git
.config(
604 for key
, value
in _read_config_from_null_list(config_output
):
605 renamed_keys
[key
.lower()] = key
606 yield system_scope
, key
, value
, False
608 gitconfig_home
= core
.expanduser(os
.path
.join('~', '.gitconfig'))
609 gitconfig_xdg
= resources
.xdg_config_home('git', 'config')
611 if os
.path
.exists(gitconfig_home
):
612 gitconfig
= gitconfig_home
613 elif os
.path
.exists(gitconfig_xdg
):
614 gitconfig
= gitconfig_xdg
619 cache_paths
.add(gitconfig
)
620 status
, config_output
, _
= context
.git
.config(
621 z
=True, list=True, includes
=includes
, **{'global': True}
624 for key
, value
in _read_config_from_null_list(config_output
):
625 renamed_keys
[key
.lower()] = key
626 yield global_scope
, key
, value
, False
628 local_config
= context
.git
.git_path('config')
629 if local_config
and os
.path
.exists(local_config
):
630 cache_paths
.add(gitconfig
)
631 status
, config_output
, _
= context
.git
.config(
638 for key
, value
in _read_config_from_null_list(config_output
):
639 renamed_keys
[key
.lower()] = key
640 yield local_scope
, key
, value
, False
643 def _read_config_from_null_list(config_output
):
644 """Parse the "git config --list -z" records"""
645 for record
in config_output
.rstrip('\0').split('\0'):
647 name
, value
= record
.split('\n', 1)
651 yield (name
, _config_to_python(value
))
654 def python_to_git(value
):
655 if isinstance(value
, bool):
656 return 'true' if value
else 'false'
657 if isinstance(value
, int_types
):
662 def get_remotes(cfg
):
663 """Get all of the configured git remotes"""
664 # Gather all of the remote.*.url entries.
665 prefix
= len('remote.')
667 return sorted(key
[prefix
:-suffix
] for key
in cfg
.find('remote.*.url'))