1from __future__ import division, absolute_import, unicode_literals 2from functools import partial 3import errno 4import os 5from os.path import join 6import subprocess 7import threading 8 9from . import core 10from .compat import int_types 11from .compat import ustr 12from .compat import WIN32 13from .decorators import memoize 14from .interaction import Interaction 15 16 17GIT_COLA_TRACE = core.getenv('GIT_COLA_TRACE', '') 18GIT = core.getenv('GIT_COLA_GIT', 'git') 19STATUS = 0 20STDOUT = 1 21STDERR = 2 22 23# Object ID / SHA1-related constants 24# Git's empty tree is a built-in constant object name. 25EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' 26# Git's diff machinery returns zeroes for modified files whose content exists 27# in the worktree only. 28MISSING_BLOB_OID = '0000000000000000000000000000000000000000' 29# Git's SHA-1 object IDs are 40 characters long. 30# This will need to change when Git moves away from SHA-1. 31# When that happens we'll have to detect and update this at runtime in 32# order to support both old and new git. 33OID_LENGTH = 40 34 35_index_lock = threading.Lock() 36 37 38def dashify(s): 39 return s.replace('_', '-') 40 41 42def is_git_dir(git_dir): 43 """From git's setup.c:is_git_directory().""" 44 result = False 45 if git_dir: 46 headref = join(git_dir, 'HEAD') 47 48 if ( 49 core.isdir(git_dir) 50 and ( 51 core.isdir(join(git_dir, 'objects')) 52 and core.isdir(join(git_dir, 'refs')) 53 ) 54 or ( 55 core.isfile(join(git_dir, 'gitdir')) 56 and core.isfile(join(git_dir, 'commondir')) 57 ) 58 ): 59 60 result = core.isfile(headref) or ( 61 core.islink(headref) and core.readlink(headref).startswith('refs/') 62 ) 63 else: 64 result = is_git_file(git_dir) 65 66 return result 67 68 69def is_git_file(f): 70 return core.isfile(f) and os.path.basename(f) == '.git' 71 72 73def is_git_worktree(d): 74 return is_git_dir(join(d, '.git')) 75 76 77def is_git_repository(path): 78 return is_git_worktree(path) or is_git_dir(path) 79 80 81def read_git_file(path): 82 """Read the path from a .git-file 83 84 `None` is returned when <path> is not a .git-file. 85 86 """ 87 result = None 88 if path and is_git_file(path): 89 header = 'gitdir: ' 90 data = core.read(path).strip() 91 if data.startswith(header): 92 result = data[len(header) :] 93 if result and not os.path.isabs(result): 94 path_folder = os.path.dirname(path) 95 repo_relative = join(path_folder, result) 96 result = os.path.normpath(repo_relative) 97 return result 98 99 100class Paths(object): 101 """Git repository paths of interest""" 102 103 def __init__(self, git_dir=None, git_file=None, worktree=None, common_dir=None): 104 if git_dir and not is_git_dir(git_dir): 105 git_dir = None 106 self.git_dir = git_dir 107 self.git_file = git_file 108 self.worktree = worktree 109 self.common_dir = common_dir 110 111 def get(self, path): 112 ceiling_dirs = set() 113 ceiling = core.getenv('GIT_CEILING_DIRECTORIES') 114 if ceiling: 115 ceiling_dirs.update([x for x in ceiling.split(':') if x]) 116 117 if path: 118 path = core.abspath(path) 119 120 if not self.git_dir or not self.worktree: 121 # Search for a .git directory 122 while path: 123 if path in ceiling_dirs: 124 break 125 if is_git_dir(path): 126 if not self.git_dir: 127 self.git_dir = path 128 basename = os.path.basename(path) 129 if not self.worktree and basename == '.git': 130 self.worktree = os.path.dirname(path) 131 # We are either in a bare repository, or someone set GIT_DIR 132 # but did not set GIT_WORK_TREE. 133 if self.git_dir: 134 if not self.worktree: 135 basename = os.path.basename(self.git_dir) 136 if basename == '.git': 137 self.worktree = os.path.dirname(self.git_dir) 138 elif path and not is_git_dir(path): 139 self.worktree = path 140 break 141 gitpath = join(path, '.git') 142 if is_git_dir(gitpath): 143 if not self.git_dir: 144 self.git_dir = gitpath 145 if not self.worktree: 146 self.worktree = path 147 break 148 path, dummy = os.path.split(path) 149 if not dummy: 150 break 151 152 if self.git_dir: 153 git_dir_path = read_git_file(self.git_dir) 154 if git_dir_path: 155 self.git_file = self.git_dir 156 self.git_dir = git_dir_path 157 158 commondir_file = join(git_dir_path, 'commondir') 159 if core.exists(commondir_file): 160 common_path = core.read(commondir_file).strip() 161 if common_path: 162 if os.path.isabs(common_path): 163 common_dir = common_path 164 else: 165 common_dir = join(git_dir_path, common_path) 166 common_dir = os.path.normpath(common_dir) 167 self.common_dir = common_dir 168 # usage: Paths().get() 169 return self 170 171 172def find_git_directory(path): 173 """Perform Git repository discovery""" 174 return Paths( 175 git_dir=core.getenv('GIT_DIR'), worktree=core.getenv('GIT_WORK_TREE') 176 ).get(path) 177 178 179class Git(object): 180 """ 181 The Git class manages communication with the Git binary 182 """ 183 184 def __init__(self): 185 self.paths = Paths() 186 187 self._valid = {} #: Store the result of is_git_dir() for performance 188 self.set_worktree(core.getcwd()) 189 190 # pylint: disable=no-self-use 191 def is_git_repository(self, path): 192 return is_git_repository(path) 193 194 def getcwd(self): 195 """Return the working directory used by git()""" 196 return self.paths.worktree or self.paths.git_dir 197 198 def set_worktree(self, path): 199 path = core.decode(path) 200 self.paths = find_git_directory(path) 201 return self.paths.worktree 202 203 def worktree(self): 204 if not self.paths.worktree: 205 path = core.abspath(core.getcwd()) 206 self.paths = find_git_directory(path) 207 return self.paths.worktree 208 209 def is_valid(self): 210 """Is this a valid git repository? 211 212 Cache the result to avoid hitting the filesystem. 213 214 """ 215 git_dir = self.paths.git_dir 216 try: 217 valid = bool(git_dir) and self._valid[git_dir] 218 except KeyError: 219 valid = self._valid[git_dir] = is_git_dir(git_dir) 220 221 return valid 222 223 def git_path(self, *paths): 224 result = None 225 if self.paths.git_dir: 226 result = join(self.paths.git_dir, *paths) 227 if result and self.paths.common_dir and not core.exists(result): 228 common_result = join(self.paths.common_dir, *paths) 229 if core.exists(common_result): 230 result = common_result 231 return result 232 233 def git_dir(self): 234 if not self.paths.git_dir: 235 path = core.abspath(core.getcwd()) 236 self.paths = find_git_directory(path) 237 return self.paths.git_dir 238 239 def __getattr__(self, name): 240 git_cmd = partial(self.git, name) 241 setattr(self, name, git_cmd) 242 return git_cmd 243 244 @staticmethod 245 def execute( 246 command, 247 _cwd=None, 248 _decode=True, 249 _encoding=None, 250 _raw=False, 251 _stdin=None, 252 _stderr=subprocess.PIPE, 253 _stdout=subprocess.PIPE, 254 _readonly=False, 255 _no_win32_startupinfo=False, 256 ): 257 """ 258 Execute a command and returns its output 259 260 :param command: argument list to execute. 261 :param _cwd: working directory, defaults to the current directory. 262 :param _decode: whether to decode output, defaults to True. 263 :param _encoding: default encoding, defaults to None (utf-8). 264 :param _raw: do not strip trailing whitespace. 265 :param _stdin: optional stdin filehandle. 266 :returns (status, out, err): exit status, stdout, stderr 267 268 """ 269 # Allow the user to have the command executed in their working dir. 270 if not _cwd: 271 _cwd = core.getcwd() 272 273 extra = {} 274 275 if hasattr(os, 'setsid'): 276 # SSH uses the SSH_ASKPASS variable only if the process is really 277 # detached from the TTY (stdin redirection and setting the 278 # SSH_ASKPASS environment variable is not enough). To detach a 279 # process from the console it should fork and call os.setsid(). 280 extra['preexec_fn'] = os.setsid 281 282 # Start the process 283 # Guard against thread-unsafe .git/index.lock files 284 if not _readonly: 285 _index_lock.acquire() 286 try: 287 status, out, err = core.run_command( 288 command, 289 cwd=_cwd, 290 encoding=_encoding, 291 stdin=_stdin, 292 stdout=_stdout, 293 stderr=_stderr, 294 no_win32_startupinfo=_no_win32_startupinfo, 295 **extra 296 ) 297 finally: 298 # Let the next thread in 299 if not _readonly: 300 _index_lock.release() 301 302 if not _raw and out is not None: 303 out = core.UStr(out.rstrip('\n'), out.encoding) 304 305 cola_trace = GIT_COLA_TRACE 306 if cola_trace == 'trace': 307 msg = 'trace: ' + core.list2cmdline(command) 308 Interaction.log_status(status, msg, '') 309 elif cola_trace == 'full': 310 if out or err: 311 core.print_stderr( 312 "%s -> %d: '%s' '%s'" % (' '.join(command), status, out, err) 313 ) 314 else: 315 core.print_stderr("%s -> %d" % (' '.join(command), status)) 316 elif cola_trace: 317 core.print_stderr(' '.join(command)) 318 319 # Allow access to the command's status code 320 return (status, out, err) 321 322 def git(self, cmd, *args, **kwargs): 323 # Handle optional arguments prior to calling transform_kwargs 324 # otherwise they'll end up in args, which is bad. 325 _kwargs = dict(_cwd=self.getcwd()) 326 execute_kwargs = ( 327 '_cwd', 328 '_decode', 329 '_encoding', 330 '_stdin', 331 '_stdout', 332 '_stderr', 333 '_raw', 334 '_readonly', 335 '_no_win32_startupinfo', 336 ) 337 338 for kwarg in execute_kwargs: 339 if kwarg in kwargs: 340 _kwargs[kwarg] = kwargs.pop(kwarg) 341 342 # Prepare the argument list 343 git_args = [ 344 GIT, 345 '-c', 346 'diff.suppressBlankEmpty=false', 347 '-c', 348 'log.showSignature=false', 349 dashify(cmd), 350 ] 351 opt_args = transform_kwargs(**kwargs) 352 call = git_args + opt_args 353 call.extend(args) 354 try: 355 result = self.execute(call, **_kwargs) 356 except OSError as e: 357 if WIN32 and e.errno == errno.ENOENT: 358 # see if git exists at all. on win32 it can fail with ENOENT in 359 # case of argv overflow. we should be safe from that but use 360 # defensive coding for the worst-case scenario. On UNIX 361 # we have ENAMETOOLONG but that doesn't exist on Windows. 362 if _git_is_installed(): 363 raise e 364 _print_win32_git_hint() 365 result = (1, '', "error: unable to execute '%s'" % GIT) 366 return result 367 368 369def _git_is_installed(): 370 """Return True if git is installed""" 371 # On win32 Git commands can fail with ENOENT in case of argv overflow. we 372 # should be safe from that but use defensive coding for the worst-case 373 # scenario. On UNIX we have ENAMETOOLONG but that doesn't exist on 374 # Windows. 375 try: 376 status, _, _ = Git.execute([GIT, '--version']) 377 result = status == 0 378 except OSError: 379 result = False 380 return result 381 382 383def transform_kwargs(**kwargs): 384 """Transform kwargs into git command line options 385 386 Callers can assume the following behavior: 387 388 Passing foo=None ignores foo, so that callers can 389 use default values of None that are ignored unless 390 set explicitly. 391 392 Passing foo=False ignore foo, for the same reason. 393 394 Passing foo={string-or-number} results in ['--foo=<value>'] 395 in the resulting arguments. 396 397 """ 398 args = [] 399 types_to_stringify = (ustr, float, str) + int_types 400 401 for k, v in kwargs.items(): 402 if len(k) == 1: 403 dashes = '-' 404 eq = '' 405 else: 406 dashes = '--' 407 eq = '=' 408 # isinstance(False, int) is True, so we have to check bool first 409 if isinstance(v, bool): 410 if v: 411 args.append('%s%s' % (dashes, dashify(k))) 412 # else: pass # False is ignored; flag=False inhibits --flag 413 elif isinstance(v, types_to_stringify): 414 args.append('%s%s%s%s' % (dashes, dashify(k), eq, v)) 415 416 return args 417 418 419def win32_git_error_hint(): 420 return ( 421 '\n' 422 'NOTE: If you have Git installed in a custom location, e.g.\n' 423 'C:\\Tools\\Git, then you can create a file at\n' 424 '~/.config/git-cola/git-bindir with following text\n' 425 'and git-cola will add the specified location to your $PATH\n' 426 'automatically when starting cola:\n' 427 '\n' 428 r'C:\Tools\Git\bin' 429 ) 430 431 432@memoize 433def _print_win32_git_hint(): 434 hint = '\n' + win32_git_error_hint() + '\n' 435 core.print_stderr("error: unable to execute 'git'" + hint) 436 437 438def create(): 439 """Create Git instances 440 441 >>> git = create() 442 >>> status, out, err = git.version() 443 >>> 'git' == out[:3].lower() 444 True 445 446 """ 447 return Git() 448