controllers: added a merge controller
[ugit.git] / ugit / git.py
blob5583d736b34b32ac17179b8987a71b985d9bf7d5
1 import os
2 import re
3 import sys
4 import types
5 import utils
6 import defaults
7 from cStringIO import StringIO
9 # union of functions in this file and dynamic functions
10 # defined in the git command string list below
11 def git(*args,**kwargs):
12 """This is a convenience wrapper around utils.run_cmd that
13 sets things up so that commands are run in the canonical
14 'git command [options] [args]' form."""
15 cmd = 'git %s' % args[0]
16 return utils.run_cmd(cmd, *args[1:], **kwargs)
18 class GitCommand(object):
19 """This class wraps this module so that arbitrary git commands
20 can be dynamically called at runtime."""
21 def __init__(self, module):
22 self.module = module
23 self.commands = {}
24 # This creates git.foo() methods dynamically for each of the
25 # following names at import-time.
26 for cmd in """
27 add
28 apply
29 branch
30 checkout
31 cherry_pick
32 commit
33 diff
34 fetch
35 format_patch
36 grep
37 log
38 ls_tree
39 merge
40 pull
41 push
42 rebase
43 remote
44 reset
45 read_tree
46 rev_list
48 show
49 status
50 tag
51 """.split(): getattr(self, cmd)
53 def setup_commands(self):
54 # Import the functions from the module
55 for name, val in self.module.__dict__.iteritems():
56 if type(val) is types.FunctionType:
57 setattr(self, name, val)
58 # Import dynamic functions and those from the module
59 # functions into self.commands
60 for name, val in self.__dict__.iteritems():
61 if type(val) is types.FunctionType:
62 self.commands[name] = val
64 def __getattr__(self, name):
65 if hasattr(self.module, name):
66 value = getattr(self.module, name)
67 setattr(self, name, value)
68 return value
69 def git_cmd(*args, **kwargs):
70 return git(name.replace('_','-'), *args, **kwargs)
71 setattr(self, name, git_cmd)
72 return git_cmd
74 # core git wrapper for use in this module
75 gitcmd = GitCommand(sys.modules[__name__])
76 sys.modules[__name__] = gitcmd
78 #+-------------------------------------------------------------------------
79 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
80 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
82 def quote(argv):
83 return ' '.join([ utils.shell_quote(arg) for arg in argv ])
85 def abort_merge():
86 return gitcmd.read_tree("HEAD", reset=True, u=True, v=True)
88 def add_or_remove(*to_process):
89 """Invokes 'git add' to index the filenames in to_process that exist
90 and 'git rm' for those that do not exist."""
92 if not to_process:
93 return 'No files to add or remove.'
95 to_add = []
96 to_remove = []
98 for filename in to_process:
99 if os.path.exists(filename):
100 to_add.append(filename)
102 output = gitcmd.add(verbose=True, *to_add)
104 if len(to_add) == len(to_process):
105 # to_process only contained unremoved files --
106 # short-circuit the removal checks
107 return output
109 # Process files to remote
110 for filename in to_process:
111 if not os.path.exists(filename):
112 to_remove.append(filename)
113 output + '\n\n' + gitcmd.rm(*to_remove)
115 def branch_list(remote=False):
116 branches = map(lambda x: x.lstrip('* '),
117 gitcmd.branch(r=remote).splitlines())
118 if remote:
119 remotes = []
120 for branch in branches:
121 if branch.endswith('/HEAD'):
122 continue
123 remotes.append(branch)
124 return remotes
125 return branches
127 def cherry_pick_list(revs, **kwargs):
128 """Cherry-picks each revision into the current branch.
129 Returns a list of command output strings (1 per cherry pick)"""
130 if not revs:
131 return []
132 cherries = []
133 for rev in revs:
134 cherries.append(gitcmd.cherry_pick(rev, **kwargs))
135 return '\n'.join(cherries)
137 def commit_with_msg(msg, amend=False):
138 """Creates a git commit."""
140 if not msg.endswith('\n'):
141 msg += '\n'
142 # Sure, this is a potential "security risk," but if someone
143 # is trying to intercept/re-write commit messages on your system,
144 # then you probably have bigger problems to worry about.
145 tmpfile = utils.get_tmp_filename()
146 kwargs = {
147 'F': tmpfile,
148 'amend': amend,
150 # Create the commit message file
151 file = open(tmpfile, 'w')
152 file.write(msg)
153 file.close()
155 # Run 'git commit'
156 output = gitcmd.commit(F=tmpfile, amend=amend)
157 os.unlink(tmpfile)
159 return ('git commit -F %s --amend %s\n\n%s'
160 % ( tmpfile, amend, output ))
162 def create_branch(name, base, track=False):
163 """Creates a branch starting from base. Pass track=True
164 to create a remote tracking branch."""
165 return gitcmd.branch(name, base, track=track)
167 def current_branch():
168 """Parses 'git branch' to find the current branch."""
170 branches = gitcmd.branch().splitlines()
171 for branch in branches:
172 if branch.startswith('* '):
173 return branch.lstrip('* ')
174 return 'Detached HEAD'
176 def diff_helper(commit=None,
177 filename=None,
178 color=False,
179 cached=True,
180 with_diff_header=False,
181 suppress_header=True,
182 reverse=False):
183 "Invokes git diff on a filepath."
185 argv = []
186 if commit:
187 argv.append('%s^..%s' % (commit, commit))
189 if filename:
190 argv.append('--')
191 if type(filename) is list:
192 argv.extend(filename)
193 else:
194 argv.append(filename)
196 diff = gitcmd.diff(
197 R=reverse,
198 color=color,
199 cached=cached,
200 patch_with_raw=True,
201 unified = defaults.DIFF_CONTEXT,
202 *argv
203 ).splitlines()
205 output = StringIO()
206 start = False
207 del_tag = 'deleted file mode '
209 headers = []
210 deleted = cached and not os.path.exists(filename)
211 for line in diff:
212 if not start and '@@ ' in line and ' @@' in line:
213 start = True
214 if start or(deleted and del_tag in line):
215 output.write(line + '\n')
216 else:
217 if with_diff_header:
218 headers.append(line)
219 elif not suppress_header:
220 output.write(line + '\n')
221 result = output.getvalue()
222 output.close()
223 if with_diff_header:
224 return('\n'.join(headers), result)
225 else:
226 return result
228 def diffstat():
229 return gitcmd.diff(
230 'HEAD^',
231 unified=defaults.DIFF_CONTEXT,
232 stat=True)
234 def diffindex():
235 return gitcmd.diff(
236 unified=defaults.DIFF_CONTEXT,
237 stat=True,
238 cached=True)
240 def format_patch_helper(*revs):
241 """writes patches named by revs to the "patches" directory."""
242 num_patches = 1
243 output = []
244 for idx, rev in enumerate(revs):
245 real_idx = idx + num_patches
246 revarg = '%s^..%s' % (rev,rev)
247 output.append(
248 gitcmd.format_patch(
249 revarg,
250 o='patches',
251 start_number=real_idx,
252 n=len(revs) > 1,
253 thread=True,
254 patch_with_stat=True
257 num_patches += output[-1].count('\n')
258 return '\n'.join(output)
260 def gitpath(name):
261 return os.path.join('.git', name)
263 def get_merge_message():
264 return gitcmd.fmt_merge_msg('--file', gitpath('FETCH_HEAD'))
266 def config_dict(local=True):
267 if local:
268 argv = [ '--list' ]
269 else:
270 argv = ['--global', '--list' ]
271 return config_to_dict(
272 gitcmd.config(*argv).splitlines())
274 def config_set(key=None, value=None, local=True):
275 if key and value is not None:
276 # git config category.key value
277 strval = str(value)
278 if type(value) is bool:
279 # git uses "true" and "false"
280 strval = strval.lower()
281 if local:
282 argv = [ key, strval ]
283 else:
284 argv = [ '--global', key, strval ]
285 return gitcmd.config(*argv)
286 else:
287 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
288 raise Exception(msg % (key, value, local))
290 def config_to_dict(config_lines):
291 """parses the lines from git config --list into a dictionary"""
293 newdict = {}
294 for line in config_lines:
295 k, v = line.split('=')
296 k = k.replace('.','_') # git -> model
297 if v == 'true' or v == 'false':
298 v = bool(eval(v.title()))
299 try:
300 v = int(eval(v))
301 except:
302 pass
303 newdict[k]=v
304 return newdict
306 def log_helper(all=False):
307 """Returns a pair of parallel arrays listing the revision sha1's
308 and commit summaries."""
309 revs = []
310 summaries = []
311 regex = REV_LIST_REGEX
312 output = gitcmd.log(pretty='oneline', all=all)
313 for line in output.splitlines():
314 match = regex.match(line)
315 if match:
316 revs.append(match.group(1))
317 summaries.append(match.group(2))
318 return( revs, summaries )
320 def parse_ls_tree(rev):
321 """Returns a list of(mode, type, sha1, path) tuples."""
322 lines = gitcmd.ls_tree(rev, r=True).splitlines()
323 output = []
324 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
325 for line in lines:
326 match = regex.match(line)
327 if match:
328 mode = match.group(1)
329 objtype = match.group(2)
330 sha1 = match.group(3)
331 filename = match.group(4)
332 output.append((mode, objtype, sha1, filename,) )
333 return output
335 def push_helper(remote, local_branch, remote_branch, ffwd=True, tags=False):
336 if ffwd:
337 branch_arg = '%s:%s' % ( local_branch, remote_branch )
338 else:
339 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
340 return gitcmd.push(remote, branch_arg, with_status=True, tags=tags)
342 def remote_url(name):
343 return gitcmd.config('remote.%s.url' % name, get=True)
345 def rev_list_range(start, end):
346 range = '%s..%s' % ( start, end )
347 raw_revs = gitcmd.rev_list(range, pretty='oneline')
348 return parse_rev_list(raw_revs)
350 def reset_helper(*args, **kwargs):
351 return gitcmd.reset('--', *args, **kwargs)
353 def parse_rev_list(raw_revs):
354 revs = []
355 for line in raw_revs.splitlines():
356 match = REV_LIST_REGEX.match(line)
357 if match:
358 rev_id = match.group(1)
359 summary = match.group(2)
360 revs.append((rev_id, summary,) )
361 return revs
363 def parse_status():
364 """RETURNS: A tuple of staged, unstaged and untracked file lists."""
366 MODIFIED_TAG = '# Changed but not updated:'
367 UNTRACKED_TAG = '# Untracked files:'
369 RGX_RENAMED = re.compile(
370 '(#\trenamed:\s+)'
371 '(.*?)\s->\s(.*)'
374 RGX_MODIFIED = re.compile(
375 '(#\tmodified:\s+'
376 '|#\tnew file:\s+'
377 '|#\tdeleted:\s+)'
379 staged = []
380 unstaged = []
381 untracked = []
383 STAGED_MODE = 0
384 UNSTAGED_MODE = 1
385 UNTRACKED_MODE = 2
387 mode = STAGED_MODE
388 current_dest = staged
390 for status_line in gitcmd.status().splitlines():
391 if status_line == MODIFIED_TAG:
392 mode = UNSTAGED_MODE
393 current_dest = unstaged
394 continue
396 elif status_line == UNTRACKED_TAG:
397 mode = UNTRACKED_MODE
398 current_dest = untracked
399 continue
401 # Staged/unstaged modified/renamed/deleted files
402 if mode == STAGED_MODE or mode == UNSTAGED_MODE:
403 match = RGX_MODIFIED.match(status_line)
404 if match:
405 tag = match.group(0)
406 filename = status_line.replace(tag, '')
407 current_dest.append(filename)
408 continue
409 match = RGX_RENAMED.match(status_line)
410 if match:
411 oldname = match.group(2)
412 newname = match.group(3)
413 current_dest.append(oldname)
414 current_dest.append(newname)
415 continue
416 # Untracked files
417 elif mode is UNTRACKED_MODE:
418 if status_line.startswith('#\t'):
419 current_dest.append(status_line[2:])
421 return( staged, unstaged, untracked )
423 # Must be executed after all functions are defined
424 gitcmd.setup_commands()