CHANGES: add a link to issue 890
[git-cola.git] / cola / git.py
blob0862954455c010d4e36f2446737ec7c8c18e9823
1 from __future__ import division, absolute_import, unicode_literals
2 from functools import partial
3 import errno
4 import os
5 from os.path import join
6 import subprocess
7 import threading
9 from . import core
10 from .compat import int_types
11 from .compat import ustr
12 from .compat import WIN32
13 from .decorators import memoize
14 from .interaction import Interaction
17 GIT_COLA_TRACE = core.getenv('GIT_COLA_TRACE', '')
18 GIT = core.getenv('GIT_COLA_GIT', 'git')
19 STATUS = 0
20 STDOUT = 1
21 STDERR = 2
23 # Object ID / SHA1-related constants
24 # Git's empty tree is a built-in constant object name.
25 EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
26 # Git's diff machinery returns zeroes for modified files whose content exists
27 # in the worktree only.
28 MISSING_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.
33 OID_LENGTH = 40
35 _index_lock = threading.Lock()
38 def dashify(s):
39 return s.replace('_', '-')
42 def 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')
48 if (
49 core.isdir(git_dir)
50 and (
51 core.isdir(join(git_dir, 'objects'))
52 and core.isdir(join(git_dir, 'refs'))
54 or (
55 core.isfile(join(git_dir, 'gitdir'))
56 and core.isfile(join(git_dir, 'commondir'))
60 result = core.isfile(headref) or (
61 core.islink(headref) and core.readlink(headref).startswith('refs/')
63 else:
64 result = is_git_file(git_dir)
66 return result
69 def is_git_file(f):
70 return core.isfile(f) and os.path.basename(f) == '.git'
73 def is_git_worktree(d):
74 return is_git_dir(join(d, '.git'))
77 def is_git_repository(path):
78 return is_git_worktree(path) or is_git_dir(path)
81 def read_git_file(path):
82 """Read the path from a .git-file
84 `None` is returned when <path> is not a .git-file.
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
100 class Paths(object):
101 """Git repository paths of interest"""
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
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])
117 if path:
118 path = core.abspath(path)
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
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
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
172 def find_git_directory(path):
173 """Perform Git repository discovery
176 return Paths(
177 git_dir=core.getenv('GIT_DIR'), worktree=core.getenv('GIT_WORK_TREE')
178 ).get(path)
181 class Git(object):
183 The Git class manages communication with the Git binary
186 def __init__(self):
187 self.paths = Paths()
189 self._valid = {} #: Store the result of is_git_dir() for performance
190 self.set_worktree(core.getcwd())
192 # pylint: disable=no-self-use
193 def is_git_repository(self, path):
194 return is_git_repository(path)
196 def getcwd(self):
197 """Return the working directory used by git()"""
198 return self.paths.worktree or self.paths.git_dir
200 def set_worktree(self, path):
201 path = core.decode(path)
202 self.paths = find_git_directory(path)
203 return self.paths.worktree
205 def worktree(self):
206 if not self.paths.worktree:
207 path = core.abspath(core.getcwd())
208 self.paths = find_git_directory(path)
209 return self.paths.worktree
211 def is_valid(self):
212 """Is this a valid git repository?
214 Cache the result to avoid hitting the filesystem.
217 git_dir = self.paths.git_dir
218 try:
219 valid = bool(git_dir) and self._valid[git_dir]
220 except KeyError:
221 valid = self._valid[git_dir] = is_git_dir(git_dir)
223 return valid
225 def git_path(self, *paths):
226 result = None
227 if self.paths.git_dir:
228 result = join(self.paths.git_dir, *paths)
229 if result and self.paths.common_dir and not core.exists(result):
230 common_result = join(self.paths.common_dir, *paths)
231 if core.exists(common_result):
232 result = common_result
233 return result
235 def git_dir(self):
236 if not self.paths.git_dir:
237 path = core.abspath(core.getcwd())
238 self.paths = find_git_directory(path)
239 return self.paths.git_dir
241 def __getattr__(self, name):
242 git_cmd = partial(self.git, name)
243 setattr(self, name, git_cmd)
244 return git_cmd
246 @staticmethod
247 def execute(
248 command,
249 _cwd=None,
250 _decode=True,
251 _encoding=None,
252 _raw=False,
253 _stdin=None,
254 _stderr=subprocess.PIPE,
255 _stdout=subprocess.PIPE,
256 _readonly=False,
257 _no_win32_startupinfo=False,
260 Execute a command and returns its output
262 :param command: argument list to execute.
263 :param _cwd: working directory, defaults to the current directory.
264 :param _decode: whether to decode output, defaults to True.
265 :param _encoding: default encoding, defaults to None (utf-8).
266 :param _raw: do not strip trailing whitespace.
267 :param _stdin: optional stdin filehandle.
268 :returns (status, out, err): exit status, stdout, stderr
271 # Allow the user to have the command executed in their working dir.
272 if not _cwd:
273 _cwd = core.getcwd()
275 extra = {}
277 if hasattr(os, 'setsid'):
278 # SSH uses the SSH_ASKPASS variable only if the process is really
279 # detached from the TTY (stdin redirection and setting the
280 # SSH_ASKPASS environment variable is not enough). To detach a
281 # process from the console it should fork and call os.setsid().
282 extra['preexec_fn'] = os.setsid
284 # Start the process
285 # Guard against thread-unsafe .git/index.lock files
286 if not _readonly:
287 _index_lock.acquire()
288 try:
289 status, out, err = core.run_command(
290 command,
291 cwd=_cwd,
292 encoding=_encoding,
293 stdin=_stdin,
294 stdout=_stdout,
295 stderr=_stderr,
296 no_win32_startupinfo=_no_win32_startupinfo,
297 **extra
299 finally:
300 # Let the next thread in
301 if not _readonly:
302 _index_lock.release()
304 if not _raw and out is not None:
305 out = core.UStr(out.rstrip('\n'), out.encoding)
307 cola_trace = GIT_COLA_TRACE
308 if cola_trace == 'trace':
309 msg = 'trace: ' + core.list2cmdline(command)
310 Interaction.log_status(status, msg, '')
311 elif cola_trace == 'full':
312 if out or err:
313 core.print_stderr(
314 "%s -> %d: '%s' '%s'" % (' '.join(command), status, out, err)
316 else:
317 core.print_stderr("%s -> %d" % (' '.join(command), status))
318 elif cola_trace:
319 core.print_stderr(' '.join(command))
321 # Allow access to the command's status code
322 return (status, out, err)
324 def git(self, cmd, *args, **kwargs):
325 # Handle optional arguments prior to calling transform_kwargs
326 # otherwise they'll end up in args, which is bad.
327 _kwargs = dict(_cwd=self.getcwd())
328 execute_kwargs = (
329 '_cwd',
330 '_decode',
331 '_encoding',
332 '_stdin',
333 '_stdout',
334 '_stderr',
335 '_raw',
336 '_readonly',
337 '_no_win32_startupinfo',
340 for kwarg in execute_kwargs:
341 if kwarg in kwargs:
342 _kwargs[kwarg] = kwargs.pop(kwarg)
344 # Prepare the argument list
345 git_args = [
346 GIT,
347 '-c',
348 'diff.suppressBlankEmpty=false',
349 '-c',
350 'log.showSignature=false',
351 dashify(cmd),
353 opt_args = transform_kwargs(**kwargs)
354 call = git_args + opt_args
355 call.extend(args)
356 try:
357 result = self.execute(call, **_kwargs)
358 except OSError as e:
359 if WIN32 and e.errno == errno.ENOENT:
360 # see if git exists at all. on win32 it can fail with ENOENT in
361 # case of argv overflow. we should be safe from that but use
362 # defensive coding for the worst-case scenario. On UNIX
363 # we have ENAMETOOLONG but that doesn't exist on Windows.
364 if _git_is_installed():
365 raise e
366 _print_win32_git_hint()
367 result = (1, '', "error: unable to execute '%s'" % GIT)
368 return result
371 def _git_is_installed():
372 """Return True if git is installed"""
373 # On win32 Git commands can fail with ENOENT in case of argv overflow. we
374 # should be safe from that but use defensive coding for the worst-case
375 # scenario. On UNIX we have ENAMETOOLONG but that doesn't exist on
376 # Windows.
377 try:
378 status, _, _ = Git.execute([GIT, '--version'])
379 result = status == 0
380 except OSError:
381 result = False
382 return result
385 def transform_kwargs(**kwargs):
386 """Transform kwargs into git command line options
388 Callers can assume the following behavior:
390 Passing foo=None ignores foo, so that callers can
391 use default values of None that are ignored unless
392 set explicitly.
394 Passing foo=False ignore foo, for the same reason.
396 Passing foo={string-or-number} results in ['--foo=<value>']
397 in the resulting arguments.
400 args = []
401 types_to_stringify = (ustr, float, str) + int_types
403 for k, v in kwargs.items():
404 if len(k) == 1:
405 dashes = '-'
406 eq = ''
407 else:
408 dashes = '--'
409 eq = '='
410 # isinstance(False, int) is True, so we have to check bool first
411 if isinstance(v, bool):
412 if v:
413 args.append('%s%s' % (dashes, dashify(k)))
414 # else: pass # False is ignored; flag=False inhibits --flag
415 elif isinstance(v, types_to_stringify):
416 args.append('%s%s%s%s' % (dashes, dashify(k), eq, v))
418 return args
421 def win32_git_error_hint():
422 return (
423 '\n'
424 'NOTE: If you have Git installed in a custom location, e.g.\n'
425 'C:\\Tools\\Git, then you can create a file at\n'
426 '~/.config/git-cola/git-bindir with following text\n'
427 'and git-cola will add the specified location to your $PATH\n'
428 'automatically when starting cola:\n'
429 '\n'
430 r'C:\Tools\Git\bin'
434 @memoize
435 def _print_win32_git_hint():
436 hint = '\n' + win32_git_error_hint() + '\n'
437 core.print_stderr("error: unable to execute 'git'" + hint)
440 def create():
441 """Create Git instances
443 >>> git = create()
444 >>> status, out, err = git.version()
445 >>> 'git' == out[:3].lower()
446 True
449 return Git()