doc: update v3.3 release notes draft
[git-cola.git] / cola / git.py
blobc38b97bcfbdd0ec8b6b8394e7235b2b21e6dc004
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 (core.isdir(git_dir) and
49 (core.isdir(join(git_dir, 'objects')) and
50 core.isdir(join(git_dir, 'refs'))) or
51 (core.isfile(join(git_dir, 'gitdir')) and
52 core.isfile(join(git_dir, 'commondir')))):
54 result = (core.isfile(headref) or
55 (core.islink(headref) and
56 core.readlink(headref).startswith('refs/')))
57 else:
58 result = is_git_file(git_dir)
60 return result
63 def is_git_file(f):
64 return core.isfile(f) and os.path.basename(f) == '.git'
67 def is_git_worktree(d):
68 return is_git_dir(join(d, '.git'))
71 def is_git_repository(path):
72 return is_git_worktree(path) or is_git_dir(path)
75 def read_git_file(path):
76 """Read the path from a .git-file
78 `None` is returned when <path> is not a .git-file.
80 """
81 result = None
82 if path and is_git_file(path):
83 header = 'gitdir: '
84 data = core.read(path).strip()
85 if data.startswith(header):
86 result = data[len(header):]
87 if result and not os.path.isabs(result):
88 path_folder = os.path.dirname(path)
89 repo_relative = join(path_folder, result)
90 result = os.path.normpath(repo_relative)
91 return result
94 class Paths(object):
95 """Git repository paths of interest"""
97 def __init__(self, git_dir=None, git_file=None,
98 worktree=None, common_dir=None):
99 self.git_dir = git_dir
100 self.git_file = git_file
101 self.worktree = worktree
102 self.common_dir = common_dir
105 def find_git_directory(curpath):
106 """Perform Git repository discovery
109 paths = Paths(git_dir=core.getenv('GIT_DIR'),
110 worktree=core.getenv('GIT_WORK_TREE'),
111 git_file=None)
112 return find_git_paths(curpath, paths)
115 def find_git_paths(curpath, paths):
116 ceiling_dirs = set()
117 ceiling = core.getenv('GIT_CEILING_DIRECTORIES')
118 if ceiling:
119 ceiling_dirs.update([x for x in ceiling.split(':') if x])
121 if not paths.git_dir or not paths.worktree:
122 if curpath:
123 curpath = core.abspath(curpath)
125 # Search for a .git directory
126 while curpath:
127 if curpath in ceiling_dirs:
128 break
129 if is_git_dir(curpath):
130 paths.git_dir = curpath
131 if os.path.basename(curpath) == '.git':
132 paths.worktree = os.path.dirname(curpath)
133 break
134 gitpath = join(curpath, '.git')
135 if is_git_dir(gitpath):
136 paths.git_dir = gitpath
137 paths.worktree = curpath
138 break
139 curpath, dummy = os.path.split(curpath)
140 if not dummy:
141 break
143 git_dir_path = read_git_file(paths.git_dir)
144 if git_dir_path:
145 paths.git_file = paths.git_dir
146 paths.git_dir = git_dir_path
148 commondir_file = join(git_dir_path, 'commondir')
149 if core.exists(commondir_file):
150 common_path = core.read(commondir_file).strip()
151 if common_path:
152 if os.path.isabs(common_path):
153 common_dir = common_path
154 else:
155 common_dir = join(git_dir_path, common_path)
156 common_dir = os.path.normpath(common_dir)
157 paths.common_dir = common_dir
159 return paths
162 class Git(object):
164 The Git class manages communication with the Git binary
166 def __init__(self):
167 self.paths = Paths()
169 self._git_cwd = None #: The working directory used by execute()
170 self._valid = {} #: Store the result of is_git_dir() for performance
171 self.set_worktree(core.getcwd())
173 # pylint: disable=no-self-use
174 def is_git_repository(self, path):
175 return is_git_repository(path)
177 def getcwd(self):
178 return self._git_cwd
180 def _find_git_directory(self, path):
181 self._git_cwd = None
182 self.paths = find_git_directory(path)
184 # Update the current directory for executing commands
185 if self.paths.worktree:
186 self._git_cwd = self.paths.worktree
187 elif self.paths.git_dir:
188 self._git_cwd = self.paths.git_dir
190 def set_worktree(self, path):
191 path = core.decode(path)
192 self._find_git_directory(path)
193 return self.paths.worktree
195 def worktree(self):
196 if not self.paths.worktree:
197 path = core.abspath(core.getcwd())
198 self._find_git_directory(path)
199 return self.paths.worktree
201 def is_valid(self):
202 """Is this a valid git repository?
204 Cache the result to avoid hitting the filesystem.
207 git_dir = self.paths.git_dir
208 try:
209 valid = bool(git_dir) and self._valid[git_dir]
210 except KeyError:
211 valid = self._valid[git_dir] = is_git_dir(git_dir)
213 return valid
215 def git_path(self, *paths):
216 result = None
217 if self.paths.git_dir:
218 result = join(self.paths.git_dir, *paths)
219 if result and self.paths.common_dir and not core.exists(result):
220 common_result = join(self.paths.common_dir, *paths)
221 if core.exists(common_result):
222 result = common_result
223 return result
225 def git_dir(self):
226 if not self.paths.git_dir:
227 path = core.abspath(core.getcwd())
228 self._find_git_directory(path)
229 return self.paths.git_dir
231 def __getattr__(self, name):
232 git_cmd = partial(self.git, name)
233 setattr(self, name, git_cmd)
234 return git_cmd
236 @staticmethod
237 def execute(command,
238 _cwd=None,
239 _decode=True,
240 _encoding=None,
241 _raw=False,
242 _stdin=None,
243 _stderr=subprocess.PIPE,
244 _stdout=subprocess.PIPE,
245 _readonly=False,
246 _no_win32_startupinfo=False):
248 Execute a command and returns its output
250 :param command: argument list to execute.
251 :param _cwd: working directory, defaults to the current directory.
252 :param _decode: whether to decode output, defaults to True.
253 :param _encoding: default encoding, defaults to None (utf-8).
254 :param _raw: do not strip trailing whitespace.
255 :param _stdin: optional stdin filehandle.
256 :returns (status, out, err): exit status, stdout, stderr
259 # Allow the user to have the command executed in their working dir.
260 if not _cwd:
261 _cwd = core.getcwd()
263 extra = {}
265 if hasattr(os, 'setsid'):
266 # SSH uses the SSH_ASKPASS variable only if the process is really
267 # detached from the TTY (stdin redirection and setting the
268 # SSH_ASKPASS environment variable is not enough). To detach a
269 # process from the console it should fork and call os.setsid().
270 extra['preexec_fn'] = os.setsid
272 # Start the process
273 # Guard against thread-unsafe .git/index.lock files
274 if not _readonly:
275 _index_lock.acquire()
276 try:
277 status, out, err = core.run_command(
278 command, cwd=_cwd, encoding=_encoding,
279 stdin=_stdin, stdout=_stdout, stderr=_stderr,
280 no_win32_startupinfo=_no_win32_startupinfo, **extra)
281 finally:
282 # Let the next thread in
283 if not _readonly:
284 _index_lock.release()
286 if not _raw and out is not None:
287 out = core.UStr(out.rstrip('\n'), out.encoding)
289 cola_trace = GIT_COLA_TRACE
290 if cola_trace == 'trace':
291 msg = 'trace: ' + core.list2cmdline(command)
292 Interaction.log_status(status, msg, '')
293 elif cola_trace == 'full':
294 if out or err:
295 core.print_stderr(
296 "%s -> %d: '%s' '%s'"
297 % (' '.join(command), status, out, err))
298 else:
299 core.print_stderr("%s -> %d" % (' '.join(command), status))
300 elif cola_trace:
301 core.print_stderr(' '.join(command))
303 # Allow access to the command's status code
304 return (status, out, err)
306 def git(self, cmd, *args, **kwargs):
307 # Handle optional arguments prior to calling transform_kwargs
308 # otherwise they'll end up in args, which is bad.
309 _kwargs = dict(_cwd=self._git_cwd)
310 execute_kwargs = (
311 '_cwd',
312 '_decode',
313 '_encoding',
314 '_stdin',
315 '_stdout',
316 '_stderr',
317 '_raw',
318 '_readonly',
319 '_no_win32_startupinfo',
322 for kwarg in execute_kwargs:
323 if kwarg in kwargs:
324 _kwargs[kwarg] = kwargs.pop(kwarg)
326 # Prepare the argument list
327 git_args = [GIT, '-c', 'diff.suppressBlankEmpty=false', dashify(cmd)]
328 opt_args = transform_kwargs(**kwargs)
329 call = git_args + opt_args
330 call.extend(args)
331 try:
332 result = self.execute(call, **_kwargs)
333 except OSError as e:
334 if WIN32 and e.errno == errno.ENOENT:
335 # see if git exists at all. on win32 it can fail with ENOENT in
336 # case of argv overflow. we should be safe from that but use
337 # defensive coding for the worst-case scenario. On UNIX
338 # we have ENAMETOOLONG but that doesn't exist on Windows.
339 if _git_is_installed():
340 raise e
341 _print_win32_git_hint()
342 result = (1, '', "error: unable to execute '%s'" % GIT)
343 return result
346 def _git_is_installed():
347 """Return True if git is installed"""
348 # On win32 Git commands can fail with ENOENT in case of argv overflow. we
349 # should be safe from that but use defensive coding for the worst-case
350 # scenario. On UNIX we have ENAMETOOLONG but that doesn't exist on
351 # Windows.
352 try:
353 status, _, _ = Git.execute([GIT, '--version'])
354 result = status == 0
355 except OSError:
356 result = False
357 return result
360 def transform_kwargs(**kwargs):
361 """Transform kwargs into git command line options
363 Callers can assume the following behavior:
365 Passing foo=None ignores foo, so that callers can
366 use default values of None that are ignored unless
367 set explicitly.
369 Passing foo=False ignore foo, for the same reason.
371 Passing foo={string-or-number} results in ['--foo=<value>']
372 in the resulting arguments.
375 args = []
376 types_to_stringify = (ustr, float, str) + int_types
378 for k, v in kwargs.items():
379 if len(k) == 1:
380 dashes = '-'
381 eq = ''
382 else:
383 dashes = '--'
384 eq = '='
385 # isinstance(False, int) is True, so we have to check bool first
386 if isinstance(v, bool):
387 if v:
388 args.append('%s%s' % (dashes, dashify(k)))
389 # else: pass # False is ignored; flag=False inhibits --flag
390 elif isinstance(v, types_to_stringify):
391 args.append('%s%s%s%s' % (dashes, dashify(k), eq, v))
393 return args
396 def win32_git_error_hint():
397 return (
398 '\n'
399 'NOTE: If you have Git installed in a custom location, e.g.\n'
400 'C:\\Tools\\Git, then you can create a file at\n'
401 '~/.config/git-cola/git-bindir with following text\n'
402 'and git-cola will add the specified location to your $PATH\n'
403 'automatically when starting cola:\n'
404 '\n'
405 r'C:\Tools\Git\bin')
408 @memoize
409 def _print_win32_git_hint():
410 hint = '\n' + win32_git_error_hint() + '\n'
411 core.print_stderr("error: unable to execute 'git'" + hint)
414 def create():
415 """Create Git instances
417 >>> git = create()
418 >>> status, out, err = git.version()
419 >>> 'git' == out[:3].lower()
420 True
423 return Git()