cola: allow working in a bare repository
[git-cola.git] / cola / git.py
blob0ee6ed9b9f6196c0b4175384585006e2a70b99c4
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(d):
30 """From git's setup.c:is_git_directory()."""
31 if (core.isdir(d) and core.isdir(join(d, 'objects')) and
32 core.isdir(join(d, 'refs'))):
33 headref = join(d, 'HEAD')
34 return (core.isfile(headref) or
35 (core.islink(headref) and
36 core.readlink(headref).startswith('refs')))
38 return is_git_file(d)
41 def is_git_file(f):
42 return core.isfile(f) and '.git' == os.path.basename(f)
45 def is_git_worktree(d):
46 return is_git_dir(join(d, '.git'))
49 def read_git_file(path):
50 """Read the path from a .git-file
52 `None` is returned when <path> is not a .git-file.
54 """
55 result = None
56 if path and is_git_file(path):
57 header = 'gitdir: '
58 data = core.read(path).strip()
59 if data.startswith(header):
60 result = data[len(header):]
61 return result
64 class Git(object):
65 """
66 The Git class manages communication with the Git binary
67 """
68 def __init__(self):
69 self._worktree = None
70 self._git_file_path = None
71 self._git_cwd = None #: The working directory used by execute()
72 self.set_worktree(core.getcwd())
74 def getcwd(self):
75 return self._git_cwd
77 def set_worktree(self, path):
78 self._git_dir = core.decode(path)
79 self._git_file_path = None
80 self._worktree = None
81 return self.worktree()
83 def worktree(self):
84 if self._worktree:
85 return self._worktree
86 self.git_dir()
87 if self._git_dir:
88 curdir = self._git_dir
89 else:
90 curdir = core.getcwd()
92 if is_git_dir(join(curdir, '.git')):
93 return curdir
95 # Handle bare repositories
96 if (len(os.path.basename(curdir)) > 4
97 and curdir.endswith('.git')):
98 return curdir
99 if 'GIT_WORK_TREE' in os.environ:
100 self._worktree = core.getenv('GIT_WORK_TREE')
101 if not self._worktree or not core.isdir(self._worktree):
102 if self._git_dir:
103 gitparent = join(core.abspath(self._git_dir), '..')
104 self._worktree = core.abspath(gitparent)
105 self.set_cwd(self._worktree)
106 return self._worktree
108 def is_valid(self):
109 return self._git_dir and is_git_dir(self._git_dir)
111 def git_path(self, *paths):
112 if self._git_file_path is None:
113 return join(self.git_dir(), *paths)
114 else:
115 return join(self._git_file_path, *paths)
117 def git_dir(self):
118 if self.is_valid():
119 return self._git_dir
120 if 'GIT_DIR' in os.environ:
121 self._git_dir = core.getenv('GIT_DIR')
122 if self._git_dir:
123 curpath = core.abspath(self._git_dir)
124 else:
125 curpath = core.abspath(core.getcwd())
126 # Search for a .git directory
127 while curpath:
128 if is_git_dir(curpath):
129 self._git_dir = curpath
130 break
131 gitpath = join(curpath, '.git')
132 if is_git_dir(gitpath):
133 self._git_dir = gitpath
134 break
135 curpath, dummy = os.path.split(curpath)
136 if not dummy:
137 break
138 self._git_file_path = read_git_file(self._git_dir)
139 return self._git_dir
141 def set_cwd(self, path):
142 """Sets the current directory."""
143 self._git_cwd = path
145 def __getattr__(self, name):
146 git_cmd = functools.partial(self.git, name)
147 setattr(self, name, git_cmd)
148 return git_cmd
150 @staticmethod
151 def execute(command,
152 _cwd=None,
153 _decode=True,
154 _encoding=None,
155 _raw=False,
156 _stdin=None,
157 _stderr=subprocess.PIPE,
158 _stdout=subprocess.PIPE,
159 _readonly=False):
161 Execute a command and returns its output
163 :param command: argument list to execute.
164 :param _cwd: working directory, defaults to the current directory.
165 :param _decode: whether to decode output, defaults to True.
166 :param _encoding: default encoding, defaults to None (utf-8).
167 :param _raw: do not strip trailing whitespace.
168 :param _stdin: optional stdin filehandle.
169 :returns (status, out, err): exit status, stdout, stderr
172 # Allow the user to have the command executed in their working dir.
173 if not _cwd:
174 _cwd = core.getcwd()
176 extra = {}
177 if sys.platform == 'win32':
178 # If git-cola is invoked on Windows using "start pythonw git-cola",
179 # a console window will briefly flash on the screen each time
180 # git-cola invokes git, which is very annoying. The code below
181 # prevents this by ensuring that any window will be hidden.
182 startupinfo = subprocess.STARTUPINFO()
183 startupinfo.dwFlags = subprocess.STARTF_USESHOWWINDOW
184 startupinfo.wShowWindow = subprocess.SW_HIDE
185 extra['startupinfo'] = startupinfo
187 if hasattr(os, 'setsid'):
188 # SSH uses the SSH_ASKPASS variable only if the process is really
189 # detached from the TTY (stdin redirection and setting the
190 # SSH_ASKPASS environment variable is not enough). To detach a
191 # process from the console it should fork and call os.setsid().
192 extra['preexec_fn'] = os.setsid
194 # Start the process
195 # Guard against thread-unsafe .git/index.lock files
196 if not _readonly:
197 INDEX_LOCK.acquire()
198 status, out, err = core.run_command(command,
199 cwd=_cwd,
200 encoding=_encoding,
201 stdin=_stdin, stdout=_stdout, stderr=_stderr,
202 **extra)
203 # Let the next thread in
204 if not _readonly:
205 INDEX_LOCK.release()
207 if not _raw and out is not None:
208 out = out.rstrip('\n')
210 cola_trace = GIT_COLA_TRACE
211 if cola_trace == 'trace':
212 msg = 'trace: ' + core.list2cmdline(command)
213 Interaction.log_status(status, msg, '')
214 elif cola_trace == 'full':
215 if out or err:
216 core.stderr("%s -> %d: '%s' '%s'" %
217 (' '.join(command), status, out, err))
218 else:
219 core.stderr("%s -> %d" % (' '.join(command), status))
220 elif cola_trace:
221 core.stderr(' '.join(command))
223 # Allow access to the command's status code
224 return (status, out, err)
226 def transform_kwargs(self, **kwargs):
227 """Transform kwargs into git command line options
229 Callers can assume the following behavior:
231 Passing foo=None ignores foo, so that callers can
232 use default values of None that are ignored unless
233 set explicitly.
235 Passing foo=False ignore foo, for the same reason.
237 Passing foo={string-or-number} results in ['--foo=<value>']
238 in the resulting arguments.
241 args = []
242 types_to_stringify = set((ustr, float, str) + int_types)
244 for k, v in kwargs.items():
245 if len(k) == 1:
246 dashes = '-'
247 join = ''
248 else:
249 dashes = '--'
250 join = '='
251 type_of_value = type(v)
252 if v is True:
253 args.append('%s%s' % (dashes, dashify(k)))
254 elif type_of_value in types_to_stringify:
255 args.append('%s%s%s%s' % (dashes, dashify(k), join, v))
257 return args
259 def git(self, cmd, *args, **kwargs):
260 # Handle optional arguments prior to calling transform_kwargs
261 # otherwise they'll end up in args, which is bad.
262 _kwargs = dict(_cwd=self._git_cwd)
263 execute_kwargs = (
264 '_cwd',
265 '_decode',
266 '_encoding',
267 '_stdin',
268 '_stdout',
269 '_stderr',
270 '_raw',
271 '_readonly',
273 for kwarg in execute_kwargs:
274 if kwarg in kwargs:
275 _kwargs[kwarg] = kwargs.pop(kwarg)
277 # Prepare the argument list
278 git_args = ['git', '-c', 'diff.suppressBlankEmpty=false', dashify(cmd)]
279 opt_args = self.transform_kwargs(**kwargs)
280 call = git_args + opt_args
281 call.extend(args)
282 try:
283 return self.execute(call, **_kwargs)
284 except OSError as e:
285 if e.errno != errno.ENOENT:
286 raise e
287 core.stderr("error: unable to execute 'git'\n"
288 "error: please ensure that 'git' is in your $PATH")
289 if sys.platform == 'win32':
290 hint = ('\n'
291 'hint: If you have Git installed in a custom location, e.g.\n'
292 'hint: C:\\Tools\\Git, then you can create a file at\n'
293 'hint: ~/.config/git-cola/git-bindir with the following text\n'
294 'hint: and git-cola will add the specified location to your $PATH\n'
295 'hint: automatically when starting cola:\n'
296 'hint:\n'
297 'hint: C:\\Tools\\Git\\bin\n')
298 core.stderr(hint)
299 sys.exit(1)
302 @memoize
303 def current():
304 """Return the Git singleton"""
305 return Git()
308 git = current()
310 Git command singleton
312 >>> from cola.git import git
313 >>> from cola.git import STDOUT
314 >>> 'git' == git.version()[STDOUT][:3].lower()
315 True