git-cola v2.11
[git-cola.git] / cola / git.py
bloba5ccdc93db4627854ed46c5c1ad1c6e4a2fd09fe
1 from __future__ import division, absolute_import, unicode_literals
3 import functools
4 import errno
5 import os
6 import sys
7 import subprocess
8 import threading
9 from os.path import join
11 from . import core
12 from .compat import int_types
13 from .compat import ustr
14 from .decorators import memoize
15 from .interaction import Interaction
18 INDEX_LOCK = threading.Lock()
19 GIT_COLA_TRACE = core.getenv('GIT_COLA_TRACE', '')
20 STATUS = 0
21 STDOUT = 1
22 STDERR = 2
25 def dashify(s):
26 return s.replace('_', '-')
29 def is_git_dir(git_dir):
30 """From git's setup.c:is_git_directory()."""
31 result = False
32 if git_dir:
33 headref = join(git_dir, 'HEAD')
35 if (core.isdir(git_dir) and
36 (core.isdir(join(git_dir, 'objects')) and
37 core.isdir(join(git_dir, 'refs'))) or
38 (core.isfile(join(git_dir, 'gitdir')) and
39 core.isfile(join(git_dir, 'commondir')))):
41 result = (core.isfile(headref) or
42 (core.islink(headref) and
43 core.readlink(headref).startswith('refs/')))
44 else:
45 result = is_git_file(git_dir)
47 return result
50 def is_git_file(f):
51 return core.isfile(f) and '.git' == os.path.basename(f)
54 def is_git_worktree(d):
55 return is_git_dir(join(d, '.git'))
58 def read_git_file(path):
59 """Read the path from a .git-file
61 `None` is returned when <path> is not a .git-file.
63 """
64 result = None
65 if path and is_git_file(path):
66 header = 'gitdir: '
67 data = core.read(path).strip()
68 if data.startswith(header):
69 result = data[len(header):]
70 if result and not os.path.isabs(result):
71 path_folder = os.path.dirname(path)
72 repo_relative = os.path.join(path_folder, result)
73 result = os.path.normpath(repo_relative)
74 return result
77 class Paths(object):
78 """Git repository paths of interest"""
80 def __init__(self, git_dir=None, git_file=None, worktree=None):
81 self.git_dir = git_dir
82 self.git_file = git_file
83 self.worktree = worktree
86 def find_git_directory(curpath):
87 """Perform Git repository discovery
89 """
90 paths = Paths(git_dir=core.getenv('GIT_DIR'),
91 worktree=core.getenv('GIT_WORK_TREE'),
92 git_file=None)
94 ceiling_dirs = set()
95 ceiling = core.getenv('GIT_CEILING_DIRECTORIES')
96 if ceiling:
97 ceiling_dirs.update([x for x in ceiling.split(':') if x])
99 if not paths.git_dir or not paths.worktree:
100 if curpath:
101 curpath = core.abspath(curpath)
103 # Search for a .git directory
104 while curpath:
105 if curpath in ceiling_dirs:
106 break
107 if is_git_dir(curpath):
108 paths.git_dir = curpath
109 if os.path.basename(curpath) == '.git':
110 paths.worktree = os.path.dirname(curpath)
111 break
112 gitpath = join(curpath, '.git')
113 if is_git_dir(gitpath):
114 paths.git_dir = gitpath
115 paths.worktree = curpath
116 break
117 curpath, dummy = os.path.split(curpath)
118 if not dummy:
119 break
121 git_dir_path = read_git_file(paths.git_dir)
122 if git_dir_path:
123 paths.git_file = paths.git_dir
124 paths.git_dir = git_dir_path
126 return paths
129 class Git(object):
131 The Git class manages communication with the Git binary
133 def __init__(self):
134 self.paths = Paths()
136 self._git_cwd = None #: The working directory used by execute()
137 self._valid = {} #: Store the result of is_git_dir() for performance
138 self.set_worktree(core.getcwd())
140 def getcwd(self):
141 return self._git_cwd
143 def _find_git_directory(self, path):
144 self._git_cwd = None
145 self.paths = find_git_directory(path)
147 # Update the current directory for executing commands
148 if self.paths.worktree:
149 self._git_cwd = self.paths.worktree
150 elif self.paths.git_dir:
151 self._git_cwd = self.paths.git_dir
153 def set_worktree(self, path):
154 path = core.decode(path)
155 self._find_git_directory(path)
156 return self.paths.worktree
158 def worktree(self):
159 if not self.paths.worktree:
160 path = core.abspath(core.getcwd())
161 self._find_git_directory(path)
162 return self.paths.worktree
164 def is_valid(self):
165 """Is this a valid git repository?
167 Cache the result to avoid hitting the filesystem.
170 git_dir = self.paths.git_dir
171 try:
172 valid = bool(git_dir) and self._valid[git_dir]
173 except KeyError:
174 valid = self._valid[git_dir] = is_git_dir(git_dir)
176 return valid
178 def git_path(self, *paths):
179 if self.paths.git_dir:
180 result = join(self.paths.git_dir, *paths)
181 else:
182 result = None
183 return result
185 def git_dir(self):
186 if not self.paths.git_dir:
187 path = core.abspath(core.getcwd())
188 self._find_git_directory(path)
189 return self.paths.git_dir
191 def __getattr__(self, name):
192 git_cmd = functools.partial(self.git, name)
193 setattr(self, name, git_cmd)
194 return git_cmd
196 @staticmethod
197 def execute(command,
198 _cwd=None,
199 _decode=True,
200 _encoding=None,
201 _raw=False,
202 _stdin=None,
203 _stderr=subprocess.PIPE,
204 _stdout=subprocess.PIPE,
205 _readonly=False,
206 _no_win32_startupinfo=False):
208 Execute a command and returns its output
210 :param command: argument list to execute.
211 :param _cwd: working directory, defaults to the current directory.
212 :param _decode: whether to decode output, defaults to True.
213 :param _encoding: default encoding, defaults to None (utf-8).
214 :param _raw: do not strip trailing whitespace.
215 :param _stdin: optional stdin filehandle.
216 :returns (status, out, err): exit status, stdout, stderr
219 # Allow the user to have the command executed in their working dir.
220 if not _cwd:
221 _cwd = core.getcwd()
223 extra = {}
225 if hasattr(os, 'setsid'):
226 # SSH uses the SSH_ASKPASS variable only if the process is really
227 # detached from the TTY (stdin redirection and setting the
228 # SSH_ASKPASS environment variable is not enough). To detach a
229 # process from the console it should fork and call os.setsid().
230 extra['preexec_fn'] = os.setsid
232 # Start the process
233 # Guard against thread-unsafe .git/index.lock files
234 if not _readonly:
235 INDEX_LOCK.acquire()
236 status, out, err = core.run_command(
237 command, cwd=_cwd, encoding=_encoding,
238 stdin=_stdin, stdout=_stdout, stderr=_stderr,
239 no_win32_startupinfo=_no_win32_startupinfo, **extra)
240 # Let the next thread in
241 if not _readonly:
242 INDEX_LOCK.release()
244 if not _raw and out is not None:
245 out = out.rstrip('\n')
247 cola_trace = GIT_COLA_TRACE
248 if cola_trace == 'trace':
249 msg = 'trace: ' + core.list2cmdline(command)
250 Interaction.log_status(status, msg, '')
251 elif cola_trace == 'full':
252 if out or err:
253 core.stderr("%s -> %d: '%s' '%s'" %
254 (' '.join(command), status, out, err))
255 else:
256 core.stderr("%s -> %d" % (' '.join(command), status))
257 elif cola_trace:
258 core.stderr(' '.join(command))
260 # Allow access to the command's status code
261 return (status, out, err)
263 def transform_kwargs(self, **kwargs):
264 """Transform kwargs into git command line options
266 Callers can assume the following behavior:
268 Passing foo=None ignores foo, so that callers can
269 use default values of None that are ignored unless
270 set explicitly.
272 Passing foo=False ignore foo, for the same reason.
274 Passing foo={string-or-number} results in ['--foo=<value>']
275 in the resulting arguments.
278 args = []
279 types_to_stringify = set((ustr, float, str) + int_types)
281 for k, v in kwargs.items():
282 if len(k) == 1:
283 dashes = '-'
284 join = ''
285 else:
286 dashes = '--'
287 join = '='
288 type_of_value = type(v)
289 if v is True:
290 args.append('%s%s' % (dashes, dashify(k)))
291 elif type_of_value in types_to_stringify:
292 args.append('%s%s%s%s' % (dashes, dashify(k), join, v))
294 return args
296 def git(self, cmd, *args, **kwargs):
297 # Handle optional arguments prior to calling transform_kwargs
298 # otherwise they'll end up in args, which is bad.
299 _kwargs = dict(_cwd=self._git_cwd)
300 execute_kwargs = (
301 '_cwd',
302 '_decode',
303 '_encoding',
304 '_stdin',
305 '_stdout',
306 '_stderr',
307 '_raw',
308 '_readonly',
309 '_no_win32_startupinfo',
312 for kwarg in execute_kwargs:
313 if kwarg in kwargs:
314 _kwargs[kwarg] = kwargs.pop(kwarg)
316 # Prepare the argument list
317 git_args = ['git', '-c', 'diff.suppressBlankEmpty=false', dashify(cmd)]
318 opt_args = self.transform_kwargs(**kwargs)
319 call = git_args + opt_args
320 call.extend(args)
321 try:
322 return self.execute(call, **_kwargs)
323 except OSError as e:
324 if e.errno != errno.ENOENT:
325 raise e
327 if WIN32:
328 # see if git exists at all. on win32 it can fail with ENOENT in
329 # case of argv overflow. we should be safe from that but use
330 # defensive coding for the worst-case scenario. on other OS-en
331 # we have ENAMETOOLONG which doesn't exist in with32 API.
332 status, out, err = self.execute(['git', '--version'])
333 if status == 0:
334 raise e
336 core.stderr("error: unable to execute 'git'\n"
337 "error: please ensure that 'git' is in your $PATH")
338 if sys.platform == 'win32':
339 _print_win32_git_hint()
340 sys.exit(1)
343 def _print_win32_git_hint():
344 hint = ('\n'
345 'hint: If you have Git installed in a custom location, e.g.\n'
346 'hint: C:\\Tools\\Git, then you can create a file at\n'
347 'hint: ~/.config/git-cola/git-bindir with following text\n'
348 'hint: and git-cola will add the specified location to your $PATH\n'
349 'hint: automatically when starting cola:\n'
350 'hint:\n'
351 'hint: C:\\Tools\\Git\\bin\n')
352 core.stderr(hint)
355 @memoize
356 def current():
357 """Return the Git singleton"""
358 return Git()
361 git = current()
363 Git command singleton
365 >>> git = current()
366 >>> 'git' == git.version()[0][:3].lower()
367 True