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