prefs: apply flake8 suggestions
[git-cola.git] / cola / git.py
blobeeba0c53ed58735227afc165e4a99aa7f9569cda
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 cola import core
12 from cola.compat import int_types
13 from cola.compat import ustr
14 from cola.decorators import memoize
15 from cola.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 return result
73 class Paths(object):
74 """Git repository paths of interest"""
76 def __init__(self, git_dir=None, git_file=None, worktree=None):
77 self.git_dir = git_dir
78 self.git_file = git_file
79 self.worktree = worktree
82 def find_git_directory(curpath):
83 """Perform Git repository discovery
85 """
86 paths = Paths(git_dir=core.getenv('GIT_DIR'),
87 worktree=core.getenv('GIT_WORKTREE'),
88 git_file=None)
90 ceiling_dirs = set()
91 ceiling = core.getenv('GIT_CEILING_DIRECTORIES')
92 if ceiling:
93 ceiling_dirs.update([x for x in ceiling.split(':') if x])
95 if not paths.git_dir or not paths.worktree:
96 if curpath:
97 curpath = core.abspath(curpath)
99 # Search for a .git directory
100 while curpath:
101 if curpath in ceiling_dirs:
102 break
103 if is_git_dir(curpath):
104 paths.git_dir = curpath
105 if os.path.basename(curpath) == '.git':
106 paths.worktree = os.path.dirname(curpath)
107 break
108 gitpath = join(curpath, '.git')
109 if is_git_dir(gitpath):
110 paths.git_dir = gitpath
111 paths.worktree = curpath
112 break
113 curpath, dummy = os.path.split(curpath)
114 if not dummy:
115 break
117 git_dir_path = read_git_file(paths.git_dir)
118 if git_dir_path:
119 paths.git_file = paths.git_dir
120 paths.git_dir = git_dir_path
122 return paths
125 class Git(object):
127 The Git class manages communication with the Git binary
129 def __init__(self):
130 self.paths = Paths()
132 self._git_cwd = None #: The working directory used by execute()
133 self._valid = {} #: Store the result of is_git_dir() for performance
134 self.set_worktree(core.getcwd())
136 def getcwd(self):
137 return self._git_cwd
139 def _find_git_directory(self, path):
140 self._git_cwd = None
141 self.paths = find_git_directory(path)
143 # Update the current directory for executing commands
144 if self.paths.worktree:
145 self._git_cwd = self.paths.worktree
146 elif self.paths.git_dir:
147 self._git_cwd = self.paths.git_dir
149 def set_worktree(self, path):
150 path = core.decode(path)
151 self._find_git_directory(path)
152 return self.paths.worktree
154 def worktree(self):
155 if not self.paths.worktree:
156 path = core.abspath(core.getcwd())
157 self._find_git_directory(path)
158 return self.paths.worktree
160 def is_valid(self):
161 """Is this a valid git repostiory?
163 Cache the result to avoid hitting the filesystem.
166 git_dir = self.paths.git_dir
167 try:
168 valid = bool(git_dir) and self._valid[git_dir]
169 except KeyError:
170 valid = self._valid[git_dir] = is_git_dir(git_dir)
172 return valid
174 def git_path(self, *paths):
175 if self.paths.git_dir:
176 result = join(self.paths.git_dir, *paths)
177 else:
178 result = None
179 return result
181 def git_dir(self):
182 if not self.paths.git_dir:
183 path = core.abspath(core.getcwd())
184 self._find_git_directory(path)
185 return self.paths.git_dir
187 def __getattr__(self, name):
188 git_cmd = functools.partial(self.git, name)
189 setattr(self, name, git_cmd)
190 return git_cmd
192 @staticmethod
193 def execute(command,
194 _cwd=None,
195 _decode=True,
196 _encoding=None,
197 _raw=False,
198 _stdin=None,
199 _stderr=subprocess.PIPE,
200 _stdout=subprocess.PIPE,
201 _readonly=False):
203 Execute a command and returns its output
205 :param command: argument list to execute.
206 :param _cwd: working directory, defaults to the current directory.
207 :param _decode: whether to decode output, defaults to True.
208 :param _encoding: default encoding, defaults to None (utf-8).
209 :param _raw: do not strip trailing whitespace.
210 :param _stdin: optional stdin filehandle.
211 :returns (status, out, err): exit status, stdout, stderr
214 # Allow the user to have the command executed in their working dir.
215 if not _cwd:
216 _cwd = core.getcwd()
218 extra = {}
219 if sys.platform == 'win32':
220 # If git-cola is invoked on Windows using "start pythonw git-cola",
221 # a console window will briefly flash on the screen each time
222 # git-cola invokes git, which is very annoying. The code below
223 # prevents this by ensuring that any window will be hidden.
224 startupinfo = subprocess.STARTUPINFO()
225 startupinfo.dwFlags = subprocess.STARTF_USESHOWWINDOW
226 startupinfo.wShowWindow = subprocess.SW_HIDE
227 extra['startupinfo'] = startupinfo
229 if hasattr(os, 'setsid'):
230 # SSH uses the SSH_ASKPASS variable only if the process is really
231 # detached from the TTY (stdin redirection and setting the
232 # SSH_ASKPASS environment variable is not enough). To detach a
233 # process from the console it should fork and call os.setsid().
234 extra['preexec_fn'] = os.setsid
236 # Start the process
237 # Guard against thread-unsafe .git/index.lock files
238 if not _readonly:
239 INDEX_LOCK.acquire()
240 status, out, err = core.run_command(
241 command, cwd=_cwd, encoding=_encoding,
242 stdin=_stdin, stdout=_stdout, stderr=_stderr, **extra)
243 # Let the next thread in
244 if not _readonly:
245 INDEX_LOCK.release()
247 if not _raw and out is not None:
248 out = out.rstrip('\n')
250 cola_trace = GIT_COLA_TRACE
251 if cola_trace == 'trace':
252 msg = 'trace: ' + core.list2cmdline(command)
253 Interaction.log_status(status, msg, '')
254 elif cola_trace == 'full':
255 if out or err:
256 core.stderr("%s -> %d: '%s' '%s'" %
257 (' '.join(command), status, out, err))
258 else:
259 core.stderr("%s -> %d" % (' '.join(command), status))
260 elif cola_trace:
261 core.stderr(' '.join(command))
263 # Allow access to the command's status code
264 return (status, out, err)
266 def transform_kwargs(self, **kwargs):
267 """Transform kwargs into git command line options
269 Callers can assume the following behavior:
271 Passing foo=None ignores foo, so that callers can
272 use default values of None that are ignored unless
273 set explicitly.
275 Passing foo=False ignore foo, for the same reason.
277 Passing foo={string-or-number} results in ['--foo=<value>']
278 in the resulting arguments.
281 args = []
282 types_to_stringify = set((ustr, float, str) + int_types)
284 for k, v in kwargs.items():
285 if len(k) == 1:
286 dashes = '-'
287 join = ''
288 else:
289 dashes = '--'
290 join = '='
291 type_of_value = type(v)
292 if v is True:
293 args.append('%s%s' % (dashes, dashify(k)))
294 elif type_of_value in types_to_stringify:
295 args.append('%s%s%s%s' % (dashes, dashify(k), join, v))
297 return args
299 def git(self, cmd, *args, **kwargs):
300 # Handle optional arguments prior to calling transform_kwargs
301 # otherwise they'll end up in args, which is bad.
302 _kwargs = dict(_cwd=self._git_cwd)
303 execute_kwargs = (
304 '_cwd',
305 '_decode',
306 '_encoding',
307 '_stdin',
308 '_stdout',
309 '_stderr',
310 '_raw',
311 '_readonly',
313 for kwarg in execute_kwargs:
314 if kwarg in kwargs:
315 _kwargs[kwarg] = kwargs.pop(kwarg)
317 # Prepare the argument list
318 git_args = ['git', '-c', 'diff.suppressBlankEmpty=false', dashify(cmd)]
319 opt_args = self.transform_kwargs(**kwargs)
320 call = git_args + opt_args
321 call.extend(args)
322 try:
323 return self.execute(call, **_kwargs)
324 except OSError as e:
325 if e.errno != errno.ENOENT:
326 raise e
327 core.stderr("error: unable to execute 'git'\n"
328 "error: please ensure that 'git' is in your $PATH")
329 if sys.platform == 'win32':
330 _print_win32_git_hint()
331 sys.exit(1)
334 def _print_win32_git_hint():
335 hint = ('\n'
336 'hint: If you have Git installed in a custom location, e.g.\n'
337 'hint: C:\\Tools\\Git, then you can create a file at\n'
338 'hint: ~/.config/git-cola/git-bindir with following text\n'
339 'hint: and git-cola will add the specified location to your $PATH\n'
340 'hint: automatically when starting cola:\n'
341 'hint:\n'
342 'hint: C:\\Tools\\Git\\bin\n')
343 core.stderr(hint)
346 @memoize
347 def current():
348 """Return the Git singleton"""
349 return Git()
352 git = current()
354 Git command singleton
356 >>> from cola.git import git
357 >>> from cola.git import STDOUT
358 >>> 'git' == git.version()[STDOUT][:3].lower()
359 True