Refactor methods to allow more delegation to the model and view
[ugit.git] / ugitlibs / git.py
blob6c2219872cea08276646c27dde7108d9dc1a340a
1 '''TODO: "import stgit"'''
2 import os
3 import re
4 import types
5 import utils
6 from cStringIO import StringIO
8 from PyQt4.QtCore import QProcess
9 from PyQt4.QtCore import QObject
10 import PyQt4.QtGui
12 # A regex for matching the output of git(log|rev-list) --pretty=oneline
13 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
15 def quote(argv):
16 return ' '.join([ utils.shell_quote(arg) for arg in argv ])
18 def git(*args,**kwargs):
19 return run_cmd('git', *args, **kwargs)
21 def run_cmd(cmd, *args, **kwargs):
22 # Handle cmd as either a string or an argv list
23 if type(cmd) is str:
24 cmd = cmd.split(' ')
25 cmd += list(args)
26 else:
27 cmd = list(cmd + list(args))
29 child = QProcess()
30 child.setProcessChannelMode(QProcess.MergedChannels);
31 child.start(cmd[0], cmd[1:])
33 if not child.waitForStarted(): raise Exception("failed to start child")
34 if not child.waitForFinished(): raise Exception("failed to start child")
36 output = str(child.readAll())
38 # Allow run_cmd(argv, raw=True) for when we
39 # want the full, raw output(e.g. git cat-file)
40 if 'raw' in kwargs:
41 return output
42 else:
43 if 'with_status' in kwargs:
44 return child.exitCode(), output.rstrip()
45 else:
46 return output.rstrip()
48 def add(to_add):
49 '''Invokes 'git add' to index the filenames in to_add.'''
50 if not to_add: return 'No files to add.'
51 return git('add', *to_add)
53 def add_or_remove(to_process):
54 '''Invokes 'git add' to index the filenames in to_process that exist
55 and 'git rm' for those that do not exist.'''
57 if not to_process:
58 return 'No files to add or remove.'
60 to_add = []
61 to_remove = []
62 output = ''
64 for filename in to_process:
65 if os.path.exists(filename):
66 to_add.append(filename)
68 add(to_add)
70 if len(to_add) == len(to_process):
71 # to_process only contained unremoved files --
72 # short-circuit the removal checks
73 return
75 # Process files to remote
76 for filename in to_process:
77 if not os.path.exists(filename):
78 to_remove.append(filename)
79 git('rm',*to_remove)
81 def apply(filename, indexonly=True, reverse=False):
82 argv = ['apply']
83 if reverse: argv.append('--reverse')
84 if indexonly: argv.extend(['--index', '--cached'])
85 argv.append(filename)
86 return git(*argv)
88 def branch(name=None, remote=False, delete=False):
89 if delete and name:
90 return git('branch', '-D', name)
91 else:
92 argv = ['branch']
93 if remote: argv.append('-r')
95 branches = git(*argv).splitlines()
96 return map(lambda(x): x.lstrip('* '), branches)
98 def cat_file(objtype, sha1):
99 return git('cat-file', objtype, sha1, raw=True)
101 def cherry_pick(revs, commit=False):
102 '''Cherry-picks each revision into the current branch.'''
103 if not revs:
104 return 'No revision selected.'
105 argv = [ 'cherry-pick' ]
106 if not commit: argv.append('-n')
108 cherries = []
109 for rev in revs:
110 new_argv = argv + [rev]
111 cherries.append(git(*new_argv))
113 return os.linesep.join(cherries)
115 def checkout(rev):
116 return git('checkout', rev)
118 def commit(msg, amend=False):
119 '''Creates a git commit.'''
121 if not msg.endswith(os.linesep):
122 msg += os.linesep
124 # Sure, this is a potential "security risk," but if someone
125 # is trying to intercept/re-write commit messages on your system,
126 # then you probably have bigger problems to worry about.
127 tmpfile = utils.get_tmp_filename()
128 argv = [ 'commit', '-F', tmpfile ]
129 if amend:
130 argv.append('--amend')
132 # Create the commit message file
133 file = open(tmpfile, 'w')
134 file.write(msg)
135 file.close()
137 # Run 'git commit'
138 output = git(*argv)
139 os.unlink(tmpfile)
141 return quote(argv) + os.linesep*2 + output
143 def create_branch(name, base, track=False):
144 '''Creates a branch starting from base. Pass track=True
145 to create a remote tracking branch.'''
146 if track:
147 return git('branch', '--track', name, base)
148 else:
149 return git('branch', name, base)
151 def current_branch():
152 '''Parses 'git branch' to find the current branch.'''
153 branches = git('branch').splitlines()
154 for branch in branches:
155 if branch.startswith('* '):
156 return branch.lstrip('* ')
157 return 'Detached HEAD'
159 def diff(commit=None,filename=None, color=False,
160 cached=True, with_diff_header=False,
161 reverse=False):
162 "Invokes git diff on a filepath."
164 argv = [ 'diff']
165 if reverse: argv.append('-R')
166 if color: argv.append('--color')
167 if cached: argv.append('--cached')
169 deleted = cached and not os.path.exists(filename)
171 if filename:
172 argv.append('--')
173 argv.append(filename)
175 if commit:
176 argv.append('%s^..%s' % (commit,commit))
178 diff = git(*argv)
179 diff_lines = diff.splitlines()
181 output = StringIO()
182 start = False
183 del_tag = 'deleted file mode '
185 headers = []
186 for line in diff_lines:
187 if not start and '@@ ' in line and ' @@' in line:
188 start = True
189 if start or(deleted and del_tag in line):
190 output.write(line + '\n')
191 else:
192 headers.append(line)
194 result = output.getvalue()
195 output.close()
197 if with_diff_header:
198 return(os.linesep.join(headers), result)
199 else:
200 return result
202 def diff_stat():
203 '''Returns the latest diffstat.'''
204 return git('diff','--stat','HEAD^')
206 def format_patch(revs, use_range):
207 '''Exports patches revs in the 'ugit-patches' subdirectory.
208 If use_range is True, a commit range is passed to git format-patch.'''
210 argv = ['format-patch','--thread','--patch-with-stat',
211 '-o','ugit-patches']
212 if len(revs) > 1:
213 argv.append('-n')
214 header = 'Generated Patches:'
215 if use_range:
216 new_argv = argv + ['%s^..%s' %( revs[-1], revs[0] )]
217 return git(*new_argv)
219 output = [ header ]
220 num_patches = 1
221 for idx, rev in enumerate(revs):
222 real_idx = str(idx + num_patches)
223 new_argv = argv + ['-1', '--start-number', real_idx, rev]
224 output.append(git(*new_argv))
225 num_patches += output[-1].count(os.linesep)
226 return os.linesep.join(output)
228 def config(key, value=None):
229 '''Gets or sets git config values. If value is not None, then
230 the config key will be set. Otherwise, the config value of the
231 config key is returned.'''
232 if value is not None:
233 return git('config', key, value)
234 else:
235 return git('config', '--get', key)
237 def log(oneline=True, all=False):
238 '''Returns a pair of parallel arrays listing the revision sha1's
239 and commit summaries.'''
240 argv = [ 'log' ]
241 if oneline:
242 argv.append('--pretty=oneline')
243 if all:
244 argv.append('--all')
245 revs = []
246 summaries = []
247 regex = REV_LIST_REGEX
248 output = git(*argv)
249 for line in output.splitlines():
250 match = regex.match(line)
251 if match:
252 revs.append(match.group(1))
253 summaries.append(match.group(2))
254 return( revs, summaries )
256 def ls_files():
257 return git('ls-files').splitlines()
259 def ls_tree(rev):
260 '''Returns a list of(mode, type, sha1, path) tuples.'''
262 lines = git('ls-tree', '-r', rev).splitlines()
263 output = []
264 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
265 for line in lines:
266 match = regex.match(line)
267 if match:
268 mode = match.group(1)
269 objtype = match.group(2)
270 sha1 = match.group(3)
271 filename = match.group(4)
272 output.append((mode, objtype, sha1, filename,) )
273 return output
275 def push(remote, local_branch, remote_branch, ffwd=True, tags=False):
276 argv = ['push']
277 if tags:
278 argv.append('--tags')
279 argv.append(remote)
281 if local_branch == remote_branch:
282 argv.append(local_branch)
283 else:
284 if not ffwd and local_branch:
285 argv.append('+%s:%s' % ( local_branch, remote_branch ))
286 else:
287 argv.append('%s:%s' % ( local_branch, remote_branch ))
289 return git(with_status=True, *argv)
291 def rebase(newbase):
292 if not newbase: return
293 return git('rebase', newbase)
295 def remote(*args):
296 argv = ['remote'] + list(args)
297 return git(*argv).splitlines()
299 def remote_show(name):
300 return [ line.strip() for line in remote('show', name) ]
302 def remote_url(name):
303 return utils.grep('^URL:\s+(.*)', remote_show(name))
305 def reset(to_unstage):
306 '''Use 'git reset' to unstage files from the index.'''
307 if not to_unstage:
308 return 'No files to reset.'
310 argv = [ 'reset', '--' ]
311 argv.extend(to_unstage)
313 return git(*argv)
315 def rev_list_range(start, end):
316 argv = [ 'rev-list', '--pretty=oneline', start, end ]
317 raw_revs = git(*argv).splitlines()
318 revs = []
319 for line in raw_revs:
320 match = REV_LIST_REGEX.match(line)
321 if match:
322 rev_id = match.group(1)
323 summary = match.group(2)
324 revs.append((rev_id, summary,) )
325 return revs
327 def show(sha1, color=False):
328 cmd = 'git show '
329 if color: cmd += '--color '
330 return run_cmd(cmd + sha1)
332 def show_cdup():
333 '''Returns a relative path to the git project root.'''
334 return git('rev-parse','--show-cdup')
336 def status():
337 '''RETURNS: A tuple of staged, unstaged and untracked files.
338 ( array(staged), array(unstaged), array(untracked) )'''
340 status_lines = git('status').splitlines()
342 unstaged_header_seen = False
343 untracked_header_seen = False
345 modified_header = '# Changed but not updated:'
346 modified_regex = re.compile('(#\tmodified:\W{3}'
347 + '|#\tnew file:\W{3}'
348 + '|#\tdeleted:\W{4})')
350 renamed_regex = re.compile('(#\trenamed:\W{4})(.*?)\W->\W(.*)')
352 untracked_header = '# Untracked files:'
353 untracked_regex = re.compile('#\t(.+)')
355 staged = []
356 unstaged = []
357 untracked = []
359 # Untracked files
360 for status_line in status_lines:
361 if untracked_header in status_line:
362 untracked_header_seen = True
363 continue
364 if not untracked_header_seen:
365 continue
366 match = untracked_regex.match(status_line)
367 if match:
368 filename = match.group(1)
369 untracked.append(filename)
371 # Staged, unstaged, and renamed files
372 for status_line in status_lines:
373 if modified_header in status_line:
374 unstaged_header_seen = True
375 continue
376 match = modified_regex.match(status_line)
377 if match:
378 tag = match.group(0)
379 filename = status_line.replace(tag, '')
380 if unstaged_header_seen:
381 unstaged.append(filename)
382 else:
383 staged.append(filename)
384 continue
385 # Renamed files
386 match = renamed_regex.match(status_line)
387 if match:
388 oldname = match.group(2)
389 newname = match.group(3)
390 staged.append(oldname)
391 staged.append(newname)
393 return( staged, unstaged, untracked )
395 def tag():
396 return git('tag').splitlines()