cmds: teach cat-file usage about the dispatcher
[ugit.git] / ugit / git.py
blob8cbd32a0a4e472a87c13b94add4e65d06a79e20a
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 cherry_pick(revs, commit=False):
93 """Cherry-picks each revision into the current branch.
94 Returns a list of command output strings (1 per cherry pick)"""
96 if not revs: return []
98 argv = [ 'cherry-pick' ]
99 kwargs = {}
100 if not commit:
101 kwargs['n'] = True
103 cherries = []
104 for rev in revs:
105 new_argv = argv + [rev]
106 cherries.append(git(*new_argv, **kwargs))
108 return '\n'.join(cherries)
110 def checkout(*args):
111 return git('checkout', *args)
113 def commit_with_msg(msg, amend=False):
114 """Creates a git commit."""
116 if not msg.endswith('\n'):
117 msg += '\n'
119 # Sure, this is a potential "security risk," but if someone
120 # is trying to intercept/re-write commit messages on your system,
121 # then you probably have bigger problems to worry about.
122 tmpfile = utils.get_tmp_filename()
123 kwargs = {
124 'F': tmpfile,
125 'amend': amend,
128 # Create the commit message file
129 file = open(tmpfile, 'w')
130 file.write(msg)
131 file.close()
133 # Run 'git commit'
134 output = gitcmd.commit(F=tmpfile, amend=amend)
135 os.unlink(tmpfile)
137 return ('git commit -F %s --amend %s\n\n%s'
138 % ( tmpfile, amend, output ))
140 def create_branch(name, base, track=False):
141 """Creates a branch starting from base. Pass track=True
142 to create a remote tracking branch."""
143 return git('branch', name, base, track=track)
145 def current_branch():
146 """Parses 'git branch' to find the current branch."""
147 branches = git('branch').splitlines()
148 for branch in branches:
149 if branch.startswith('* '):
150 return branch.lstrip('* ')
151 return 'Detached HEAD'
153 def diff(commit=None,filename=None, color=False,
154 cached=True, with_diff_header=False,
155 suppress_header=True, reverse=False):
156 "Invokes git diff on a filepath."
158 argv = []
159 if commit:
160 argv.append('%s^..%s' % (commit, commit))
162 if filename:
163 argv.append('--')
164 if type(filename) is list:
165 argv.extend(filename)
166 else:
167 argv.append(filename)
169 kwargs = {
170 'patch-with-raw': True,
171 'unified': defaults.DIFF_CONTEXT,
174 diff = git('diff',
175 R=reverse,
176 color=color,
177 cached=cached,
178 *argv,
179 **kwargs)
181 diff_lines = diff.splitlines()
183 output = StringIO()
184 start = False
185 del_tag = 'deleted file mode '
187 headers = []
188 deleted = cached and not os.path.exists(filename)
189 for line in diff_lines:
190 if not start and '@@ ' in line and ' @@' in line:
191 start = True
192 if start or(deleted and del_tag in line):
193 output.write(line + '\n')
194 else:
195 if with_diff_header:
196 headers.append(line)
197 elif not suppress_header:
198 output.write(line + '\n')
199 result = output.getvalue()
200 output.close()
201 if with_diff_header:
202 return('\n'.join(headers), result)
203 else:
204 return result
206 def diffstat():
207 return git('diff', 'HEAD^',
208 unified=defaults.DIFF_CONTEXT,
209 stat=True)
211 def diffindex():
212 return git('diff',
213 unified=defaults.DIFF_CONTEXT,
214 stat=True,
215 cached=True)
217 def format_patch(revs):
218 """writes patches named by revs to the "patches" directory."""
219 num_patches = 1
220 output = []
221 kwargs = {
222 'o': 'patches',
223 'n': len(revs) > 1,
224 'thread': True,
225 'patch-with-stat': True,
227 for idx, rev in enumerate(revs):
228 real_idx = idx + num_patches
229 kwargs['start-number'] = real_idx
230 revarg = '%s^..%s'%(rev,rev)
231 output.append(git('format-patch', revarg, **kwargs))
232 num_patches += output[-1].count('\n')
233 return '\n'.join(output)
235 def config_dict(local=True):
236 if local:
237 argv = [ '--list' ]
238 else:
239 argv = ['--global', '--list' ]
240 return config_to_dict(
241 gitcmd.config(*argv).splitlines())
243 def config_set(key=None, value=None, local=True):
244 if key and value is not None:
245 # git config category.key value
246 strval = str(value)
247 if type(value) is bool:
248 # git uses "true" and "false"
249 strval = strval.lower()
250 if local:
251 argv = [ key, strval ]
252 else:
253 argv = [ '--global', key, strval ]
254 return gitcmd.config(*argv)
255 else:
256 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
257 raise Exception(msg % (key, value, local))
259 def config_to_dict(config_lines):
260 """parses the lines from git config --list into a dictionary"""
262 newdict = {}
263 for line in config_lines:
264 k, v = line.split('=')
265 k = k.replace('.','_') # git -> model
266 if v == 'true' or v == 'false':
267 v = bool(eval(v.title()))
268 try:
269 v = int(eval(v))
270 except:
271 pass
272 newdict[k]=v
273 return newdict
275 def log(oneline=True, all=False):
276 """Returns a pair of parallel arrays listing the revision sha1's
277 and commit summaries."""
278 kwargs = {}
279 if oneline:
280 kwargs['pretty'] = 'oneline'
281 revs = []
282 summaries = []
283 regex = REV_LIST_REGEX
284 output = git('log', all=all, **kwargs)
285 for line in output.splitlines():
286 match = regex.match(line)
287 if match:
288 revs.append(match.group(1))
289 summaries.append(match.group(2))
290 return( revs, summaries )
292 def ls_tree(rev):
293 """Returns a list of(mode, type, sha1, path) tuples."""
294 lines = git('ls-tree', rev, r=True).splitlines()
295 output = []
296 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
297 for line in lines:
298 match = regex.match(line)
299 if match:
300 mode = match.group(1)
301 objtype = match.group(2)
302 sha1 = match.group(3)
303 filename = match.group(4)
304 output.append((mode, objtype, sha1, filename,) )
305 return output
307 def push(remote, local_branch, remote_branch, ffwd=True, tags=False):
308 if ffwd:
309 branch_arg = '%s:%s' % ( local_branch, remote_branch )
310 else:
311 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
312 return git('push', remote, branch_arg, with_status=True, tags=tags)
314 def remote(*args):
315 return git('remote', without_stderr=True, *args).splitlines()
317 def remote_url(name):
318 return gitcmd.config('remote.%s.url' % name, get=True)
320 def rev_list_range(start, end):
321 range = '%s..%s' % ( start, end )
322 raw_revs = gitcmd.rev_list(range, pretty='oneline').splitlines()
323 revs = []
324 for line in raw_revs:
325 match = REV_LIST_REGEX.match(line)
326 if match:
327 rev_id = match.group(1)
328 summary = match.group(2)
329 revs.append((rev_id, summary,) )
330 return revs
332 def parsed_status():
333 """RETURNS: A tuple of staged, unstaged and untracked file lists."""
335 MODIFIED_TAG = '# Changed but not updated:'
336 UNTRACKED_TAG = '# Untracked files:'
338 RGX_RENAMED = re.compile(
339 '(#\trenamed:\s+)'
340 '(.*?)\s->\s(.*)'
343 RGX_MODIFIED = re.compile(
344 '(#\tmodified:\s+'
345 '|#\tnew file:\s+'
346 '|#\tdeleted:\s+)'
348 staged = []
349 unstaged = []
350 untracked = []
352 STAGED_MODE = 0
353 UNSTAGED_MODE = 1
354 UNTRACKED_MODE = 2
356 mode = STAGED_MODE
357 current_dest = staged
359 for status_line in gitcmd.status().splitlines():
360 if status_line == MODIFIED_TAG:
361 mode = UNSTAGED_MODE
362 current_dest = unstaged
363 continue
365 elif status_line == UNTRACKED_TAG:
366 mode = UNTRACKED_MODE
367 current_dest = untracked
368 continue
370 # Staged/unstaged modified/renamed/deleted files
371 if mode == STAGED_MODE or mode == UNSTAGED_MODE:
372 match = RGX_MODIFIED.match(status_line)
373 if match:
374 tag = match.group(0)
375 filename = status_line.replace(tag, '')
376 current_dest.append(filename)
377 continue
378 match = RGX_RENAMED.match(status_line)
379 if match:
380 oldname = match.group(2)
381 newname = match.group(3)
382 current_dest.append(oldname)
383 current_dest.append(newname)
384 continue
385 # Untracked files
386 elif mode is UNTRACKED_MODE:
387 if status_line.startswith('#\t'):
388 current_dest.append(status_line[2:])
390 return( staged, unstaged, untracked )