1from __future__ import division, absolute_import, unicode_literals 2from binascii import unhexlify 3import copy 4import fnmatch 5import os 6from os.path import join 7import re 8import struct 9 10from . import core 11from . import observable 12from . import utils 13from . import version 14from .compat import int_types 15from .git import STDOUT 16from .compat import ustr 17 18BUILTIN_READER = os.environ.get('GIT_COLA_BUILTIN_CONFIG_READER', False) 19 20_USER_CONFIG = core.expanduser(join('~', '.gitconfig')) 21_USER_XDG_CONFIG = core.expanduser( 22 join(core.getenv('XDG_CONFIG_HOME', join('~', '.config')), 'git', 'config') 23) 24 25 26def create(context): 27 """Create GitConfig instances""" 28 return GitConfig(context) 29 30 31def _stat_info(git): 32 # Try /etc/gitconfig as a fallback for the system config 33 paths = [ 34 ('system', '/etc/gitconfig'), 35 ('user', _USER_XDG_CONFIG), 36 ('user', _USER_CONFIG), 37 ] 38 config = git.git_path('config') 39 if config: 40 paths.append(('repo', config)) 41 42 statinfo = [] 43 for category, path in paths: 44 try: 45 statinfo.append((category, path, core.stat(path).st_mtime)) 46 except OSError: 47 continue 48 return statinfo 49 50 51def _cache_key(git): 52 # Try /etc/gitconfig as a fallback for the system config 53 paths = [ 54 '/etc/gitconfig', 55 _USER_XDG_CONFIG, 56 _USER_CONFIG, 57 ] 58 config = git.git_path('config') 59 if config: 60 paths.append(config) 61 62 mtimes = [] 63 for path in paths: 64 try: 65 mtimes.append(core.stat(path).st_mtime) 66 except OSError: 67 continue 68 return mtimes 69 70 71def _config_to_python(v): 72 """Convert a Git config string into a Python value""" 73 74 if v in ('true', 'yes'): 75 v = True 76 elif v in ('false', 'no'): 77 v = False 78 else: 79 try: 80 v = int(v) 81 except ValueError: 82 pass 83 return v 84 85 86def unhex(value): 87 """Convert a value (int or hex string) into bytes""" 88 if isinstance(value, int_types): 89 # If the value is an integer then it's a value that was converted 90 # by the config reader. Zero-pad it into a 6-digit hex number. 91 value = '%06d' % value 92 return unhexlify(core.encode(value.lstrip('#'))) 93 94 95def _config_key_value(line, splitchar): 96 """Split a config line into a (key, value) pair""" 97 98 try: 99 k, v = line.split(splitchar, 1) 100 except ValueError: 101 # the user has an empty entry in their git config, 102 # which Git interprets as meaning "true" 103 k = line 104 v = 'true' 105 return k, _config_to_python(v) 106 107 108class GitConfig(observable.Observable): 109 """Encapsulate access to git-config values.""" 110 111 message_user_config_changed = 'user_config_changed' 112 message_repo_config_changed = 'repo_config_changed' 113 message_updated = 'updated' 114 115 def __init__(self, context): 116 observable.Observable.__init__(self) 117 self.git = context.git 118 self._map = {} 119 self._system = {} 120 self._user = {} 121 self._user_or_system = {} 122 self._repo = {} 123 self._all = {} 124 self._cache_key = None 125 self._configs = [] 126 self._config_files = {} 127 self._attr_cache = {} 128 self._find_config_files() 129 130 def reset(self): 131 self._cache_key = None 132 self._configs = [] 133 self._config_files.clear() 134 self._attr_cache = {} 135 self._find_config_files() 136 self.reset_values() 137 138 def reset_values(self): 139 self._map.clear() 140 self._system.clear() 141 self._user.clear() 142 self._user_or_system.clear() 143 self._repo.clear() 144 self._all.clear() 145 146 def user(self): 147 return copy.deepcopy(self._user) 148 149 def repo(self): 150 return copy.deepcopy(self._repo) 151 152 def all(self): 153 return copy.deepcopy(self._all) 154 155 def _find_config_files(self): 156 """ 157 Classify git config files into 'system', 'user', and 'repo'. 158 159 Populates self._configs with a list of the files in 160 reverse-precedence order. self._config_files is populated with 161 {category: path} where category is one of 'system', 'user', or 'repo'. 162 163 """ 164 # Try the git config in git's installation prefix 165 statinfo = _stat_info(self.git) 166 self._configs = [x[1] for x in statinfo] 167 self._config_files = {} 168 for (cat, path, _) in statinfo: 169 self._config_files[cat] = path 170 171 def _cached(self): 172 """ 173 Return True when the cache matches. 174 175 Updates the cache and returns False when the cache does not match. 176 177 """ 178 cache_key = _cache_key(self.git) 179 if self._cache_key is None or cache_key != self._cache_key: 180 self._cache_key = cache_key 181 return False 182 return True 183 184 def update(self): 185 """Read git config value into the system, user and repo dicts.""" 186 if self._cached(): 187 return 188 189 self.reset_values() 190 191 if 'system' in self._config_files: 192 self._system.update(self.read_config(self._config_files['system'])) 193 194 if 'user' in self._config_files: 195 self._user.update(self.read_config(self._config_files['user'])) 196 197 if 'repo' in self._config_files: 198 self._repo.update(self.read_config(self._config_files['repo'])) 199 200 for dct in (self._system, self._user): 201 self._user_or_system.update(dct) 202 203 for dct in (self._system, self._user, self._repo): 204 self._all.update(dct) 205 206 self.notify_observers(self.message_updated) 207 208 def read_config(self, path): 209 """Return git config data from a path as a dictionary.""" 210 211 if BUILTIN_READER: 212 return self._read_config_file(path) 213 214 dest = {} 215 if version.check_git(self, 'config-includes'): 216 args = ('--null', '--file', path, '--list', '--includes') 217 else: 218 args = ('--null', '--file', path, '--list') 219 config_lines = self.git.config(*args)[STDOUT].split('\0') 220 for line in config_lines: 221 if not line: 222 # the user has an invalid entry in their git config 223 continue 224 k, v = _config_key_value(line, '\n') 225 self._map[k.lower()] = k 226 dest[k] = v 227 return dest 228 229 def _read_config_file(self, path): 230 """Read a .gitconfig file into a dict""" 231 232 config = {} 233 header_simple = re.compile(r'^\[(\s+)]$') 234 header_subkey = re.compile(r'^\[(\s+) "(\s+)"\]$') 235 236 with core.xopen(path, 'rt') as f: 237 file_lines = f.readlines() 238 239 stripped_lines = [line.strip() for line in file_lines] 240 lines = [line for line in stripped_lines if bool(line)] 241 prefix = '' 242 for line in lines: 243 if line.startswith('#'): 244 continue 245 246 match = header_simple.match(line) 247 if match: 248 prefix = match.group(1) + '.' 249 continue 250 match = header_subkey.match(line) 251 if match: 252 prefix = match.group(1) + '.' + match.group(2) + '.' 253 continue 254 255 k, v = _config_key_value(line, '=') 256 k = prefix + k 257 self._map[k.lower()] = k 258 config[k] = v 259 260 return config 261 262 def _get(self, src, key, default, fn=None, cached=True): 263 if not cached or not src: 264 self.update() 265 try: 266 value = self._get_with_fallback(src, key) 267 except KeyError: 268 if fn: 269 value = fn() 270 else: 271 value = default 272 return value 273 274 def _get_with_fallback(self, src, key): 275 try: 276 return src[key] 277 except KeyError: 278 pass 279 key = self._map.get(key.lower(), key) 280 try: 281 return src[key] 282 except KeyError: 283 pass 284 # Allow the final KeyError to bubble up 285 return src[key.lower()] 286 287 def get(self, key, default=None, fn=None, cached=True): 288 """Return the string value for a config key.""" 289 return self._get(self._all, key, default, fn=fn, cached=cached) 290 291 def get_all(self, key): 292 """Return all values for a key sorted in priority order 293 294 The purpose of this function is to group the values returned by 295 `git config --show-origin --get-all` so that the relative order is 296 preserved but can still be overridden at each level. 297 298 One use case is the `cola.icontheme` variable, which is an ordered 299 list of icon themes to load. This value can be set both in 300 ~/.gitconfig as well as .git/config, and we want to allow a 301 relative order to be defined in either file. 302 303 The problem is that git will read the system /etc/gitconfig, 304 global ~/.gitconfig, and then the local .git/config settings 305 and return them in that order, so we must post-process them to 306 get them in an order which makes sense for use for our values. 307 Otherwise, we cannot replace the order, or make a specific theme used 308 first, in our local .git/config since the native order returned by 309 git will always list the global config before the local one. 310 311 get_all() allows for this use case by gathering all of the per-config 312 values separately and then orders them according to the expected 313 local > global > system order. 314 315 """ 316 result = [] 317 status, out, _ = self.git.config(key, z=True, get_all=True, show_origin=True) 318 if status == 0: 319 current_source = '' 320 current_result = [] 321 partial_results = [] 322 items = [x for x in out.rstrip(chr(0)).split(chr(0)) if x] 323 for i in range(len(items) // 2): 324 source = items[i * 2] 325 value = items[i * 2 + 1] 326 if source != current_source: 327 current_source = source 328 current_result = [] 329 partial_results.append(current_result) 330 current_result.append(value) 331 # Git's results are ordered System, Global, Local. 332 # Reverse the order here so that Local has the highest priority. 333 for partial_result in reversed(partial_results): 334 result.extend(partial_result) 335 336 return result 337 338 def get_user(self, key, default=None): 339 return self._get(self._user, key, default) 340 341 def get_repo(self, key, default=None): 342 return self._get(self._repo, key, default) 343 344 def get_user_or_system(self, key, default=None): 345 return self._get(self._user_or_system, key, default) 346 347 def set_user(self, key, value): 348 if value in (None, ''): 349 self.git.config('--global', key, unset=True) 350 else: 351 self.git.config('--global', key, python_to_git(value)) 352 self.update() 353 msg = self.message_user_config_changed 354 self.notify_observers(msg, key, value) 355 356 def set_repo(self, key, value): 357 if value in (None, ''): 358 self.git.config(key, unset=True) 359 else: 360 self.git.config(key, python_to_git(value)) 361 self.update() 362 msg = self.message_repo_config_changed 363 self.notify_observers(msg, key, value) 364 365 def find(self, pat): 366 pat = pat.lower() 367 match = fnmatch.fnmatch 368 result = {} 369 if not self._all: 370 self.update() 371 for key, val in self._all.items(): 372 if match(key.lower(), pat): 373 result[key] = val 374 return result 375 376 def is_annex(self): 377 """Return True when git-annex is enabled""" 378 return bool(self.get('annex.uuid', default=False)) 379 380 def gui_encoding(self): 381 return self.get('gui.encoding', default=None) 382 383 def is_per_file_attrs_enabled(self): 384 return self.get( 385 'cola.fileattributes', fn=lambda: os.path.exists('.gitattributes') 386 ) 387 388 def file_encoding(self, path): 389 if not self.is_per_file_attrs_enabled(): 390 return self.gui_encoding() 391 cache = self._attr_cache 392 try: 393 value = cache[path] 394 except KeyError: 395 value = cache[path] = self._file_encoding(path) or self.gui_encoding() 396 return value 397 398 def _file_encoding(self, path): 399 """Return the file encoding for a path""" 400 status, out, _ = self.git.check_attr('encoding', '--', path) 401 if status != 0: 402 return None 403 header = '%s: encoding: ' % path 404 if out.startswith(header): 405 encoding = out[len(header) :].strip() 406 if encoding not in ('unspecified', 'unset', 'set'): 407 return encoding 408 return None 409 410 def get_guitool_opts(self, name): 411 """Return the guitool.<name> namespace as a dict 412 413 The dict keys are simplified so that "guitool.$name.cmd" is accessible 414 as `opts[cmd]`. 415 416 """ 417 prefix = len('guitool.%s.' % name) 418 guitools = self.find('guitool.%s.*' % name) 419 return dict([(key[prefix:], value) for (key, value) in guitools.items()]) 420 421 def get_guitool_names(self): 422 guitools = self.find('guitool.*.cmd') 423 prefix = len('guitool.') 424 suffix = len('.cmd') 425 return sorted([name[prefix:-suffix] for (name, _) in guitools.items()]) 426 427 def get_guitool_names_and_shortcuts(self): 428 """Return guitool names and their configured shortcut""" 429 names = self.get_guitool_names() 430 return [(name, self.get('guitool.%s.shortcut' % name)) for name in names] 431 432 def terminal(self): 433 term = self.get('cola.terminal', default=None) 434 if not term: 435 # find a suitable default terminal 436 term = 'xterm -e' # for mac osx 437 if utils.is_win32(): 438 # Try to find Git's sh.exe directory in 439 # one of the typical locations 440 pf = os.environ.get('ProgramFiles', 'C:\\Program Files') 441 pf32 = os.environ.get('ProgramFiles(x86)', 'C:\\Program Files (x86)') 442 pf64 = os.environ.get('ProgramW6432', 'C:\\Program Files') 443 444 for p in [pf64, pf32, pf, 'C:\\']: 445 candidate = os.path.join(p, 'Git\\bin\\sh.exe') 446 if os.path.isfile(candidate): 447 return candidate 448 return None 449 else: 450 candidates = ('xfce4-terminal', 'konsole', 'gnome-terminal') 451 for basename in candidates: 452 if core.exists('/usr/bin/%s' % basename): 453 if basename == 'gnome-terminal': 454 term = '%s --' % basename 455 else: 456 term = '%s -e' % basename 457 break 458 return term 459 460 def color(self, key, default): 461 value = self.get('cola.color.%s' % key, default=default) 462 struct_layout = core.encode('BBB') 463 try: 464 r, g, b = struct.unpack(struct_layout, unhex(value)) 465 except (struct.error, TypeError): 466 r, g, b = struct.unpack(struct_layout, unhex(default)) 467 return (r, g, b) 468 469 def hooks(self): 470 """Return the path to the git hooks directory""" 471 gitdir_hooks = self.git.git_path('hooks') 472 return self.get('core.hookspath', default=gitdir_hooks) 473 474 def hooks_path(self, *paths): 475 """Return a path from within the git hooks directory""" 476 return os.path.join(self.hooks(), *paths) 477 478 479def python_to_git(value): 480 if isinstance(value, bool): 481 return 'true' if value else 'false' 482 if isinstance(value, int_types): 483 return ustr(value) 484 return value 485