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