cmds: teach checkout and remote about the dispatcher
[ugit.git] / ugit / git.py
blob6d5029bb621bc9dab2e0b4ab52dde61a5fe1a680
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(revs):
205 """writes patches named by revs to the "patches" directory."""
206 num_patches = 1
207 output = []
208 kwargs = {
209 'o': 'patches',
210 'n': len(revs) > 1,
211 'thread': True,
212 'patch-with-stat': True,
214 for idx, rev in enumerate(revs):
215 real_idx = idx + num_patches
216 kwargs['start-number'] = real_idx
217 revarg = '%s^..%s'%(rev,rev)
218 output.append(git('format-patch', revarg, **kwargs))
219 num_patches += output[-1].count('\n')
220 return '\n'.join(output)
222 def config_dict(local=True):
223 if local:
224 argv = [ '--list' ]
225 else:
226 argv = ['--global', '--list' ]
227 return config_to_dict(
228 gitcmd.config(*argv).splitlines())
230 def config_set(key=None, value=None, local=True):
231 if key and value is not None:
232 # git config category.key value
233 strval = str(value)
234 if type(value) is bool:
235 # git uses "true" and "false"
236 strval = strval.lower()
237 if local:
238 argv = [ key, strval ]
239 else:
240 argv = [ '--global', key, strval ]
241 return gitcmd.config(*argv)
242 else:
243 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
244 raise Exception(msg % (key, value, local))
246 def config_to_dict(config_lines):
247 """parses the lines from git config --list into a dictionary"""
249 newdict = {}
250 for line in config_lines:
251 k, v = line.split('=')
252 k = k.replace('.','_') # git -> model
253 if v == 'true' or v == 'false':
254 v = bool(eval(v.title()))
255 try:
256 v = int(eval(v))
257 except:
258 pass
259 newdict[k]=v
260 return newdict
262 def log(oneline=True, all=False):
263 """Returns a pair of parallel arrays listing the revision sha1's
264 and commit summaries."""
265 kwargs = {}
266 if oneline:
267 kwargs['pretty'] = 'oneline'
268 revs = []
269 summaries = []
270 regex = REV_LIST_REGEX
271 output = git('log', all=all, **kwargs)
272 for line in output.splitlines():
273 match = regex.match(line)
274 if match:
275 revs.append(match.group(1))
276 summaries.append(match.group(2))
277 return( revs, summaries )
279 def ls_tree(rev):
280 """Returns a list of(mode, type, sha1, path) tuples."""
281 lines = git('ls-tree', rev, r=True).splitlines()
282 output = []
283 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
284 for line in lines:
285 match = regex.match(line)
286 if match:
287 mode = match.group(1)
288 objtype = match.group(2)
289 sha1 = match.group(3)
290 filename = match.group(4)
291 output.append((mode, objtype, sha1, filename,) )
292 return output
294 def push(remote, local_branch, remote_branch, ffwd=True, tags=False):
295 if ffwd:
296 branch_arg = '%s:%s' % ( local_branch, remote_branch )
297 else:
298 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
299 return git('push', remote, branch_arg, with_status=True, tags=tags)
301 def remote_url(name):
302 return gitcmd.config('remote.%s.url' % name, get=True)
304 def rev_list_range(start, end):
305 range = '%s..%s' % ( start, end )
306 raw_revs = gitcmd.rev_list(range, pretty='oneline').splitlines()
307 revs = []
308 for line in raw_revs:
309 match = REV_LIST_REGEX.match(line)
310 if match:
311 rev_id = match.group(1)
312 summary = match.group(2)
313 revs.append((rev_id, summary,) )
314 return revs
316 def parsed_status():
317 """RETURNS: A tuple of staged, unstaged and untracked file lists."""
319 MODIFIED_TAG = '# Changed but not updated:'
320 UNTRACKED_TAG = '# Untracked files:'
322 RGX_RENAMED = re.compile(
323 '(#\trenamed:\s+)'
324 '(.*?)\s->\s(.*)'
327 RGX_MODIFIED = re.compile(
328 '(#\tmodified:\s+'
329 '|#\tnew file:\s+'
330 '|#\tdeleted:\s+)'
332 staged = []
333 unstaged = []
334 untracked = []
336 STAGED_MODE = 0
337 UNSTAGED_MODE = 1
338 UNTRACKED_MODE = 2
340 mode = STAGED_MODE
341 current_dest = staged
343 for status_line in gitcmd.status().splitlines():
344 if status_line == MODIFIED_TAG:
345 mode = UNSTAGED_MODE
346 current_dest = unstaged
347 continue
349 elif status_line == UNTRACKED_TAG:
350 mode = UNTRACKED_MODE
351 current_dest = untracked
352 continue
354 # Staged/unstaged modified/renamed/deleted files
355 if mode == STAGED_MODE or mode == UNSTAGED_MODE:
356 match = RGX_MODIFIED.match(status_line)
357 if match:
358 tag = match.group(0)
359 filename = status_line.replace(tag, '')
360 current_dest.append(filename)
361 continue
362 match = RGX_RENAMED.match(status_line)
363 if match:
364 oldname = match.group(2)
365 newname = match.group(3)
366 current_dest.append(oldname)
367 current_dest.append(newname)
368 continue
369 # Untracked files
370 elif mode is UNTRACKED_MODE:
371 if status_line.startswith('#\t'):
372 current_dest.append(status_line[2:])
374 return( staged, unstaged, untracked )