cmds: teach ls_files usage about the dispatcher
[git-cola.git] / ugit / git.py
blobfded257cbccd08776efd49e5455be4a24df03d10
1 import os
2 import re
3 import sys
4 import types
5 import utils
6 import defaults
7 from cStringIO import StringIO
9 def git(*args,**kwargs):
10 """This is a convenience wrapper around run_cmd that
11 sets things up so that commands are run in the canonical
12 'git command [options] [args]' form."""
13 cmd = 'git %s' % args[0]
14 return utils.run_cmd(cmd, *args[1:], **kwargs)
16 class GitCommand(object):
17 """This class wraps this module so that arbitrary git commands
18 can be dynamically called at runtime."""
19 def __init__(self, module):
20 self.module = module
21 def __getattr__(self, name):
22 if hasattr(self.module, name):
23 return getattr(self.module, name)
24 def git_cmd(*args, **kwargs):
25 return git(name.replace('_','-'), *args, **kwargs)
26 return git_cmd
28 # At import we replace this module with a GitCommand singleton.
29 gitcmd = GitCommand(sys.modules[__name__])
30 sys.modules[__name__] = gitcmd
33 #+-------------------------------------------------------------------------
34 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
35 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
37 def quote(argv):
38 return ' '.join([ utils.shell_quote(arg) for arg in argv ])
40 def add_or_remove(*to_process):
41 """Invokes 'git add' to index the filenames in to_process that exist
42 and 'git rm' for those that do not exist."""
44 if not to_process:
45 return 'No files to add or remove.'
47 to_add = []
48 to_remove = []
50 for filename in to_process:
51 if os.path.exists(filename):
52 to_add.append(filename)
54 output = gitcmd.add(verbose=True, *to_add)
56 if len(to_add) == len(to_process):
57 # to_process only contained unremoved files --
58 # short-circuit the removal checks
59 return output
61 # Process files to remote
62 for filename in to_process:
63 if not os.path.exists(filename):
64 to_remove.append(filename)
65 output + '\n\n' + gitcmd.rm(*to_remove)
67 def apply(filename, indexonly=True, reverse=False):
68 kwargs = {}
69 if reverse:
70 kwargs['reverse'] = True
71 if indexonly:
72 kwargs['index'] = True
73 kwargs['cached'] = True
74 argv = ['apply', filename]
75 return git(*argv, **kwargs)
77 def branch(name=None, remote=False, delete=False):
78 if delete and name:
79 return git('branch', name, D=True)
80 else:
81 branches = map(lambda x: x.lstrip('* '),
82 git('branch', r=remote).splitlines())
83 if remote:
84 remotes = []
85 for branch in branches:
86 if branch.endswith('/HEAD'):
87 continue
88 remotes.append(branch)
89 return remotes
90 return branches
92 def cat_file(objtype, sha1):
93 return git('cat-file', objtype, sha1, raw=True)
95 def cherry_pick(revs, commit=False):
96 """Cherry-picks each revision into the current branch.
97 Returns a list of command output strings (1 per cherry pick)"""
99 if not revs: return []
101 argv = [ 'cherry-pick' ]
102 kwargs = {}
103 if not commit:
104 kwargs['n'] = True
106 cherries = []
107 for rev in revs:
108 new_argv = argv + [rev]
109 cherries.append(git(*new_argv, **kwargs))
111 return '\n'.join(cherries)
113 def checkout(*args):
114 return git('checkout', *args)
116 def commit_with_msg(msg, amend=False):
117 """Creates a git commit."""
119 if not msg.endswith('\n'):
120 msg += '\n'
122 # Sure, this is a potential "security risk," but if someone
123 # is trying to intercept/re-write commit messages on your system,
124 # then you probably have bigger problems to worry about.
125 tmpfile = utils.get_tmp_filename()
126 kwargs = {
127 'F': tmpfile,
128 'amend': amend,
131 # Create the commit message file
132 file = open(tmpfile, 'w')
133 file.write(msg)
134 file.close()
136 # Run 'git commit'
137 output = gitcmd.commit(F=tmpfile, amend=amend)
138 os.unlink(tmpfile)
140 return ('git commit -F %s --amend %s\n\n%s'
141 % ( tmpfile, amend, output ))
143 def create_branch(name, base, track=False):
144 """Creates a branch starting from base. Pass track=True
145 to create a remote tracking branch."""
146 return git('branch', name, base, track=track)
148 def current_branch():
149 """Parses 'git branch' to find the current branch."""
150 branches = git('branch').splitlines()
151 for branch in branches:
152 if branch.startswith('* '):
153 return branch.lstrip('* ')
154 return 'Detached HEAD'
156 def diff(commit=None,filename=None, color=False,
157 cached=True, with_diff_header=False,
158 suppress_header=True, reverse=False):
159 "Invokes git diff on a filepath."
161 argv = []
162 if commit:
163 argv.append('%s^..%s' % (commit, commit))
165 if filename:
166 argv.append('--')
167 if type(filename) is list:
168 argv.extend(filename)
169 else:
170 argv.append(filename)
172 kwargs = {
173 'patch-with-raw': True,
174 'unified': defaults.DIFF_CONTEXT,
177 diff = git('diff',
178 R=reverse,
179 color=color,
180 cached=cached,
181 *argv,
182 **kwargs)
184 diff_lines = diff.splitlines()
186 output = StringIO()
187 start = False
188 del_tag = 'deleted file mode '
190 headers = []
191 deleted = cached and not os.path.exists(filename)
192 for line in diff_lines:
193 if not start and '@@ ' in line and ' @@' in line:
194 start = True
195 if start or(deleted and del_tag in line):
196 output.write(line + '\n')
197 else:
198 if with_diff_header:
199 headers.append(line)
200 elif not suppress_header:
201 output.write(line + '\n')
202 result = output.getvalue()
203 output.close()
204 if with_diff_header:
205 return('\n'.join(headers), result)
206 else:
207 return result
209 def diffstat():
210 return git('diff', 'HEAD^',
211 unified=defaults.DIFF_CONTEXT,
212 stat=True)
214 def diffindex():
215 return git('diff',
216 unified=defaults.DIFF_CONTEXT,
217 stat=True,
218 cached=True)
220 def format_patch(revs):
221 """writes patches named by revs to the "patches" directory."""
222 num_patches = 1
223 output = []
224 kwargs = {
225 'o': 'patches',
226 'n': len(revs) > 1,
227 'thread': True,
228 'patch-with-stat': True,
230 for idx, rev in enumerate(revs):
231 real_idx = idx + num_patches
232 kwargs['start-number'] = real_idx
233 revarg = '%s^..%s'%(rev,rev)
234 output.append(git('format-patch', revarg, **kwargs))
235 num_patches += output[-1].count('\n')
236 return '\n'.join(output)
238 def config_dict(local=True):
239 if local:
240 argv = [ '--list' ]
241 else:
242 argv = ['--global', '--list' ]
243 return config_to_dict(
244 gitcmd.config(*argv).splitlines())
246 def config_set(key=None, value=None, local=True):
247 if key and value is not None:
248 # git config category.key value
249 strval = str(value)
250 if type(value) is bool:
251 # git uses "true" and "false"
252 strval = strval.lower()
253 if local:
254 argv = [ key, strval ]
255 else:
256 argv = [ '--global', key, strval ]
257 return gitcmd.config(*argv)
258 else:
259 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
260 raise Exception(msg % (key, value, local))
262 def config_to_dict(config_lines):
263 """parses the lines from git config --list into a dictionary"""
265 newdict = {}
266 for line in config_lines:
267 k, v = line.split('=')
268 k = k.replace('.','_') # git -> model
269 if v == 'true' or v == 'false':
270 v = bool(eval(v.title()))
271 try:
272 v = int(eval(v))
273 except:
274 pass
275 newdict[k]=v
276 return newdict
278 def log(oneline=True, all=False):
279 """Returns a pair of parallel arrays listing the revision sha1's
280 and commit summaries."""
281 kwargs = {}
282 if oneline:
283 kwargs['pretty'] = 'oneline'
284 revs = []
285 summaries = []
286 regex = REV_LIST_REGEX
287 output = git('log', all=all, **kwargs)
288 for line in output.splitlines():
289 match = regex.match(line)
290 if match:
291 revs.append(match.group(1))
292 summaries.append(match.group(2))
293 return( revs, summaries )
295 def ls_tree(rev):
296 """Returns a list of(mode, type, sha1, path) tuples."""
297 lines = git('ls-tree', rev, r=True).splitlines()
298 output = []
299 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
300 for line in lines:
301 match = regex.match(line)
302 if match:
303 mode = match.group(1)
304 objtype = match.group(2)
305 sha1 = match.group(3)
306 filename = match.group(4)
307 output.append((mode, objtype, sha1, filename,) )
308 return output
310 def push(remote, local_branch, remote_branch, ffwd=True, tags=False):
311 if ffwd:
312 branch_arg = '%s:%s' % ( local_branch, remote_branch )
313 else:
314 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
315 return git('push', remote, branch_arg, with_status=True, tags=tags)
317 def remote(*args):
318 return git('remote', without_stderr=True, *args).splitlines()
320 def remote_url(name):
321 return gitcmd.config('remote.%s.url' % name, get=True)
323 def rev_list_range(start, end):
324 range = '%s..%s' % ( start, end )
325 raw_revs = gitcmd.rev_list(range, pretty='oneline').splitlines()
326 revs = []
327 for line in raw_revs:
328 match = REV_LIST_REGEX.match(line)
329 if match:
330 rev_id = match.group(1)
331 summary = match.group(2)
332 revs.append((rev_id, summary,) )
333 return revs
335 def parsed_status():
336 """RETURNS: A tuple of staged, unstaged and untracked file lists."""
338 MODIFIED_TAG = '# Changed but not updated:'
339 UNTRACKED_TAG = '# Untracked files:'
341 RGX_RENAMED = re.compile(
342 '(#\trenamed:\s+)'
343 '(.*?)\s->\s(.*)'
346 RGX_MODIFIED = re.compile(
347 '(#\tmodified:\s+'
348 '|#\tnew file:\s+'
349 '|#\tdeleted:\s+)'
351 staged = []
352 unstaged = []
353 untracked = []
355 STAGED_MODE = 0
356 UNSTAGED_MODE = 1
357 UNTRACKED_MODE = 2
359 mode = STAGED_MODE
360 current_dest = staged
362 for status_line in gitcmd.status().splitlines():
363 if status_line == MODIFIED_TAG:
364 mode = UNSTAGED_MODE
365 current_dest = unstaged
366 continue
368 elif status_line == UNTRACKED_TAG:
369 mode = UNTRACKED_MODE
370 current_dest = untracked
371 continue
373 # Staged/unstaged modified/renamed/deleted files
374 if mode == STAGED_MODE or mode == UNSTAGED_MODE:
375 match = RGX_MODIFIED.match(status_line)
376 if match:
377 tag = match.group(0)
378 filename = status_line.replace(tag, '')
379 current_dest.append(filename)
380 continue
381 match = RGX_RENAMED.match(status_line)
382 if match:
383 oldname = match.group(2)
384 newname = match.group(3)
385 current_dest.append(oldname)
386 current_dest.append(newname)
387 continue
388 # Untracked files
389 elif mode is UNTRACKED_MODE:
390 if status_line.startswith('#\t'):
391 current_dest.append(status_line[2:])
393 return( staged, unstaged, untracked )