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