Added interactive diff gui
[ugit.git] / py / cmds.py
blob9721080fd816849f2d34537ea95de7fed423bf51
1 import os
2 import re
3 import commands
4 import utils
5 from cStringIO import StringIO
7 from PyQt4.QtCore import QProcess
9 # A regex for matching the output of git (log|rev-list) --pretty=oneline
10 REV_LIST_REGEX = re.compile ('([0-9a-f]+)\W(.*)')
12 def quote (argv):
13 return ' '.join ([ utils.shell_quote (arg) for arg in argv ])
15 def run_cmd (cmd, *args, **kwargs):
16 # Handle cmd as either a string or an argv list
17 if type (cmd) is str:
18 cmd = cmd.split (' ')
19 cmd += list (args)
20 else:
21 cmd = list (cmd + list (args))
23 child = QProcess()
24 child.setProcessChannelMode(QProcess.MergedChannels);
25 child.start (cmd[0], cmd[1:])
27 if (not child.waitForStarted()):
28 raise Exception, "failed to start child"
30 if (not child.waitForFinished()):
31 raise Exception, "failed to start child"
33 output = str (child.readAll())
35 # Allow run_cmd (argv, raw=True) for when we
36 # want the full, raw output (e.g. git cat-file)
37 if 'raw' in kwargs and kwargs['raw']:
38 return output
39 else:
40 return output.rstrip()
42 def git_add (to_add):
43 '''Invokes 'git add' to index the filenames in to_add.'''
44 if not to_add: return 'ERROR: No files to add.'
45 argv = [ 'git', 'add' ]
46 argv.extend (to_add)
47 return 'Running:\t' + quote (argv) + '\n' + run_cmd (argv)
49 def git_add_or_remove (to_process):
50 '''Invokes 'git add' to index the filenames in to_process that exist
51 and 'git rm' for those that do not exist.'''
53 if not to_process:
54 return 'ERROR: No files to add or remove.'
56 to_add = []
57 output = ''
59 for filename in to_process:
60 if os.path.exists (filename):
61 to_add.append (filename)
63 if to_add:
64 output += git_add (to_add) + '\n\n'
66 if len(to_add) == len(to_process):
67 # to_process only contained unremoved files --
68 # short-circuit the removal checks
69 return output
71 # Process files to add
72 argv = [ 'git', 'rm' ]
73 for filename in to_process:
74 if not os.path.exists (filename):
75 argv.append (filename)
77 return '%sRunning:\t%s\n%s' % ( output, quote (argv), run_cmd (argv) )
79 def git_apply (filename, indexonly=True):
80 argv = ['git', 'apply']
81 if indexonly:
82 argv.extend (['--index', '--cached'])
83 argv.append (filename)
84 return run_cmd (argv)
86 def git_branch (name=None, remote=False, delete=False):
87 argv = ['git', 'branch']
88 if delete and name:
89 return run_cmd (argv, '-D', name)
90 else:
91 if remote: argv.append ('-r')
93 branches = run_cmd (argv).splitlines()
94 return map (lambda (x): x.lstrip ('* '), branches)
96 def git_cat_file (objtype, sha1):
97 cmd = 'git cat-file %s %s' % ( objtype, sha1 )
98 return run_cmd (cmd, raw=True)
100 def git_cherry_pick (revs, commit=False):
101 '''Cherry-picks each revision into the current branch.'''
102 if not revs:
103 return 'ERROR: No revisions selected for cherry-picking.'
105 argv = [ 'git', 'cherry-pick' ]
106 if not commit: argv.append ('-n')
108 output = []
109 for rev in revs:
110 output.append ('Cherry-picking: ' + rev)
111 output.append (run_cmd (argv, rev))
112 output.append ('')
113 return '\n'.join (output)
115 def git_checkout(rev):
116 return run_cmd('git','checkout', rev)
118 def git_commit (msg, amend, files):
119 '''Creates a git commit. 'commit_all' triggers the -a
120 flag to 'git commit.' 'amend' triggers --amend.
121 'files' is a list of files to use for commits without -a.'''
123 # Sure, this is a potential "security risk," but if someone
124 # is trying to intercept/re-write commit messages on your system,
125 # then you probably have bigger problems to worry about.
126 tmpfile = utils.get_tmp_filename()
127 argv = [ 'git', 'commit', '-F', tmpfile ]
129 if amend: argv.append ('--amend')
131 if not files:
132 return 'ERROR: No files selected for commit.'
134 argv.append ('--')
135 argv.extend (files)
137 # Create the commit message file
138 file = open (tmpfile, 'w')
139 file.write (msg)
140 file.close()
142 # Run 'git commit'
143 output = run_cmd (argv)
144 os.unlink (tmpfile)
146 return 'Running:\t' + quote (argv) + '\n\n' + output
148 def git_create_branch (name, base, track=False):
149 '''Creates a branch starting from base. Pass track=True
150 to create a remote tracking branch.'''
151 argv = ['git','branch']
152 if track: argv.append ('--track')
153 return run_cmd (argv, name, base)
156 def git_current_branch():
157 '''Parses 'git branch' to find the current branch.'''
158 branches = run_cmd ('git branch').splitlines()
159 for branch in branches:
160 if branch.startswith ('* '):
161 return branch.lstrip ('* ')
162 raise Exception, 'No current branch. Detached HEAD?'
164 def git_diff (filename, staged=True, color=False, with_diff_header=False):
165 '''Invokes git_diff on filename. Passing staged=True adds
166 diffs the index against HEAD (i.e. --cached).'''
168 deleted = False
169 argv = [ 'git', 'diff']
170 if color:
171 argv.append ('--color')
173 if staged:
174 deleted = not os.path.exists (filename)
175 argv.append ('--cached')
177 argv.append ('--')
178 argv.append (filename)
180 diff = run_cmd (argv)
181 diff_lines = diff.splitlines()
183 output = StringIO()
184 start = False
185 del_tag = 'deleted file mode '
187 headers = []
188 for line in diff_lines:
189 if not start and '@@ ' in line and ' @@' in line:
190 start = True
191 if start or (deleted and del_tag in line):
192 output.write (line + '\n')
193 else:
194 headers.append (line)
196 result = output.getvalue()
197 output.close()
199 if with_diff_header:
200 return (os.linesep.join (headers), result)
201 else:
202 return result
204 def git_diff_stat ():
205 '''Returns the latest diffstat.'''
206 return run_cmd ('git diff --stat HEAD^')
208 def git_format_patch (revs, use_range):
209 '''Exports patches revs in the 'ugit-patches' subdirectory.
210 If use_range is True, a commit range is passed to git format-patch.'''
212 argv = ['git','format-patch','--thread','--patch-with-stat',
213 '-o','ugit-patches']
214 if len (revs) > 1:
215 argv.append ('-n')
217 header = 'Generated Patches:'
218 if use_range:
219 rev_range = '%s^..%s' % ( revs[-1], revs[0] )
220 return (header + '\n'
221 + run_cmd (argv, rev_range))
223 output = [ header ]
224 num_patches = 1
225 for idx, rev in enumerate (revs):
226 real_idx = str (idx + num_patches)
227 output.append (
228 run_cmd (argv, '-1', '--start-number', real_idx, rev))
230 num_patches += output[-1].count ('\n')
232 return '\n'.join (output)
234 def git_config(key, value=None):
235 '''Gets or sets git config values. If value is not None, then
236 the config key will be set. Otherwise, the config value of the
237 config key is returned.'''
238 if value is not None:
239 return run_cmd ('git', 'config', key, value)
240 else:
241 return run_cmd ('git', 'config', '--get', key)
243 def git_log (oneline=True, all=False):
244 '''Returns a pair of parallel arrays listing the revision sha1's
245 and commit summaries.'''
246 argv = [ 'git', 'log' ]
247 if oneline:
248 argv.append ('--pretty=oneline')
249 if all:
250 argv.append ('--all')
251 revs = []
252 summaries = []
253 regex = REV_LIST_REGEX
254 output = run_cmd (argv)
255 for line in output.splitlines():
256 match = regex.match (line)
257 if match:
258 revs.append (match.group (1))
259 summaries.append (match.group (2))
260 return ( revs, summaries )
262 def git_ls_files ():
263 return run_cmd ('git ls-files').splitlines()
265 def git_ls_tree (rev):
266 '''Returns a list of (mode, type, sha1, path) tuples.'''
268 lines = run_cmd ('git', 'ls-tree', '-r', rev).splitlines()
269 output = []
270 regex = re.compile ('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
271 for line in lines:
272 match = regex.match (line)
273 if match:
274 mode = match.group (1)
275 objtype = match.group (2)
276 sha1 = match.group (3)
277 filename = match.group (4)
278 output.append ( (mode, objtype, sha1, filename,) )
279 return output
281 def git_rebase (newbase):
282 if not newbase: return
283 return run_cmd ('git','rebase', newbase)
285 def git_reset (to_unstage):
286 '''Use 'git reset' to unstage files from the index.'''
288 if not to_unstage: return 'ERROR: No files to reset.'
290 argv = [ 'git', 'reset', '--' ]
291 argv.extend (to_unstage)
293 return 'Running:\t' + quote (argv) + '\n' + run_cmd (argv)
295 def git_rev_list_range (start, end):
297 argv = [ 'git', 'rev-list', '--pretty=oneline', start, end ]
299 raw_revs = run_cmd (argv).splitlines()
300 revs = []
301 regex = REV_LIST_REGEX
302 for line in raw_revs:
303 match = regex.match (line)
304 if match:
305 rev_id = match.group (1)
306 summary = match.group (2)
307 revs.append ( (rev_id, summary,) )
309 return revs
311 def git_show (sha1, color=False):
312 cmd = 'git show '
313 if color: cmd += '--color '
314 return run_cmd (cmd + sha1)
316 def git_show_cdup():
317 '''Returns a relative path to the git project root.'''
318 return run_cmd ('git rev-parse --show-cdup')
320 def git_status():
321 '''RETURNS: A tuple of staged, unstaged and untracked files.
322 ( array(staged), array(unstaged), array(untracked) )'''
324 status_lines = run_cmd ('git status').splitlines()
326 unstaged_header_seen = False
327 untracked_header_seen = False
329 modified_header = '# Changed but not updated:'
330 modified_regex = re.compile ('(#\tmodified:\W{3}'
331 + '|#\tnew file:\W{3}'
332 + '|#\tdeleted:\W{4})')
334 renamed_regex = re.compile ('(#\trenamed:\W{4})(.*?)\W->\W(.*)')
336 untracked_header = '# Untracked files:'
337 untracked_regex = re.compile ('#\t(.+)')
339 staged = []
340 unstaged = []
341 untracked = []
343 # Untracked files
344 for status_line in status_lines:
345 if untracked_header in status_line:
346 untracked_header_seen = True
347 continue
348 if not untracked_header_seen:
349 continue
350 match = untracked_regex.match (status_line)
351 if match:
352 filename = match.group (1)
353 untracked.append (filename)
355 # Staged, unstaged, and renamed files
356 for status_line in status_lines:
357 if modified_header in status_line:
358 unstaged_header_seen = True
359 continue
360 match = modified_regex.match (status_line)
361 if match:
362 tag = match.group (0)
363 filename = status_line.replace (tag, '')
364 if unstaged_header_seen:
365 unstaged.append (filename)
366 else:
367 staged.append (filename)
368 continue
369 # Renamed files
370 match = renamed_regex.match (status_line)
371 if match:
372 oldname = match.group (2)
373 newname = match.group (3)
374 staged.append (oldname)
375 staged.append (newname)
377 return ( staged, unstaged, untracked )
379 def git_tag ():
380 return run_cmd ('git tag').splitlines()