Make things look nicer on MacOS
[ugit.git] / py / cmds.py
blob72912fe87ad26aa47b87981e969ef1d8ab7801dc
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_branch (name=None, remote=False, delete=False):
80 argv = ['git', 'branch']
81 if delete and name:
82 return run_cmd (argv, '-D', name)
83 else:
84 if remote: argv.append ('-r')
86 branches = run_cmd (argv).splitlines()
87 return map (lambda (x): x.lstrip ('* '), branches)
89 def git_cat_file (objtype, sha1):
90 cmd = 'git cat-file %s %s' % ( objtype, sha1 )
91 return run_cmd (cmd, raw=True)
93 def git_cherry_pick (revs, commit=False):
94 '''Cherry-picks each revision into the current branch.'''
95 if not revs:
96 return 'ERROR: No revisions selected for cherry-picking.'
98 argv = [ 'git', 'cherry-pick' ]
99 if not commit: argv.append ('-n')
101 output = []
102 for rev in revs:
103 output.append ('Cherry-picking: ' + rev)
104 output.append (run_cmd (argv, rev))
105 output.append ('')
106 return '\n'.join (output)
108 def git_checkout(rev):
109 return run_cmd('git','checkout', rev)
111 def git_commit (msg, amend, files):
112 '''Creates a git commit. 'commit_all' triggers the -a
113 flag to 'git commit.' 'amend' triggers --amend.
114 'files' is a list of files to use for commits without -a.'''
116 # Sure, this is a potential "security risk," but if someone
117 # is trying to intercept/re-write commit messages on your system,
118 # then you probably have bigger problems to worry about.
119 tmpfile = utils.get_tmp_filename()
120 argv = [ 'git', 'commit', '-F', tmpfile ]
122 if amend: argv.append ('--amend')
124 if not files:
125 return 'ERROR: No files selected for commit.'
127 argv.append ('--')
128 argv.extend (files)
130 # Create the commit message file
131 file = open (tmpfile, 'w')
132 file.write (msg)
133 file.close()
135 # Run 'git commit'
136 output = run_cmd (argv)
137 os.unlink (tmpfile)
139 return 'Running:\t' + quote (argv) + '\n\n' + output
141 def git_create_branch (name, base, track=False):
142 '''Creates a branch starting from base. Pass track=True
143 to create a remote tracking branch.'''
144 argv = ['git','branch']
145 if track: argv.append ('--track')
146 return run_cmd (argv, name, base)
149 def git_current_branch():
150 '''Parses 'git branch' to find the current branch.'''
151 branches = run_cmd ('git branch').splitlines()
152 for branch in branches:
153 if branch.startswith ('* '):
154 return branch.lstrip ('* ')
155 raise Exception, 'No current branch. Detached HEAD?'
157 def git_diff (filename, staged=True, color=False):
158 '''Invokes git_diff on filename. Passing staged=True adds
159 diffs the index against HEAD (i.e. --cached).'''
161 deleted = False
162 argv = [ 'git', 'diff']
163 if color:
164 argv.append ('--color')
166 if staged:
167 deleted = not os.path.exists (filename)
168 argv.append ('--cached')
170 argv.append ('--')
171 argv.append (filename)
173 diff = run_cmd (argv)
174 diff_lines = diff.splitlines()
176 output = StringIO()
177 start = False
178 del_tag = 'deleted file mode '
180 for line in diff_lines:
181 if not start and '@@ ' in line and ' @@' in line:
182 start = True
183 if start or (deleted and del_tag in line):
184 output.write (line + '\n')
185 return output.getvalue()
187 def git_diff_stat ():
188 '''Returns the latest diffstat.'''
189 return run_cmd ('git diff --stat HEAD^')
191 def git_format_patch (revs, use_range):
192 '''Exports patches revs in the 'ugit-patches' subdirectory.
193 If use_range is True, a commit range is passed to git format-patch.'''
195 argv = ['git','format-patch','--thread','--patch-with-stat',
196 '-o','ugit-patches']
197 if len (revs) > 1:
198 argv.append ('-n')
200 header = 'Generated Patches:'
201 if use_range:
202 rev_range = '%s^..%s' % ( revs[-1], revs[0] )
203 return (header + '\n'
204 + run_cmd (argv, rev_range))
206 output = [ header ]
207 num_patches = 1
208 for idx, rev in enumerate (revs):
209 real_idx = str (idx + num_patches)
210 output.append (
211 run_cmd (argv, '-1', '--start-number', real_idx, rev))
213 num_patches += output[-1].count ('\n')
215 return '\n'.join (output)
217 def git_config(key, value=None):
218 '''Gets or sets git config values. If value is not None, then
219 the config key will be set. Otherwise, the config value of the
220 config key is returned.'''
221 if value is not None:
222 return run_cmd ('git', 'config', key, value)
223 else:
224 return run_cmd ('git', 'config', '--get', key)
226 def git_log (oneline=True, all=False):
227 '''Returns a pair of parallel arrays listing the revision sha1's
228 and commit summaries.'''
229 argv = [ 'git', 'log' ]
230 if oneline:
231 argv.append ('--pretty=oneline')
232 if all:
233 argv.append ('--all')
234 revs = []
235 summaries = []
236 regex = REV_LIST_REGEX
237 output = run_cmd (argv)
238 for line in output.splitlines():
239 match = regex.match (line)
240 if match:
241 revs.append (match.group (1))
242 summaries.append (match.group (2))
243 return ( revs, summaries )
245 def git_ls_files ():
246 return run_cmd ('git ls-files').splitlines()
248 def git_ls_tree (rev):
249 '''Returns a list of (mode, type, sha1, path) tuples.'''
251 lines = run_cmd ('git', 'ls-tree', '-r', rev).splitlines()
252 output = []
253 regex = re.compile ('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
254 for line in lines:
255 match = regex.match (line)
256 if match:
257 mode = match.group (1)
258 objtype = match.group (2)
259 sha1 = match.group (3)
260 filename = match.group (4)
261 output.append ( (mode, objtype, sha1, filename,) )
262 return output
264 def git_rebase (newbase):
265 if not newbase: return
266 return run_cmd ('git','rebase', newbase)
268 def git_reset (to_unstage):
269 '''Use 'git reset' to unstage files from the index.'''
271 if not to_unstage: return 'ERROR: No files to reset.'
273 argv = [ 'git', 'reset', '--' ]
274 argv.extend (to_unstage)
276 return 'Running:\t' + quote (argv) + '\n' + run_cmd (argv)
278 def git_rev_list_range (start, end):
280 argv = [ 'git', 'rev-list', '--pretty=oneline', start, end ]
282 raw_revs = run_cmd (argv).splitlines()
283 revs = []
284 regex = REV_LIST_REGEX
285 for line in raw_revs:
286 match = regex.match (line)
287 if match:
288 rev_id = match.group (1)
289 summary = match.group (2)
290 revs.append ( (rev_id, summary,) )
292 return revs
294 def git_show (sha1, color=False):
295 cmd = 'git show '
296 if color: cmd += '--color '
297 return run_cmd (cmd + sha1)
299 def git_show_cdup():
300 '''Returns a relative path to the git project root.'''
301 return run_cmd ('git rev-parse --show-cdup')
303 def git_status():
304 '''RETURNS: A tuple of staged, unstaged and untracked files.
305 ( array(staged), array(unstaged), array(untracked) )'''
307 status_lines = run_cmd ('git status').splitlines()
309 unstaged_header_seen = False
310 untracked_header_seen = False
312 modified_header = '# Changed but not updated:'
313 modified_regex = re.compile ('(#\tmodified:\W{3}'
314 + '|#\tnew file:\W{3}'
315 + '|#\tdeleted:\W{4})')
317 renamed_regex = re.compile ('(#\trenamed:\W{4})(.*?)\W->\W(.*)')
319 untracked_header = '# Untracked files:'
320 untracked_regex = re.compile ('#\t(.+)')
322 staged = []
323 unstaged = []
324 untracked = []
326 # Untracked files
327 for status_line in status_lines:
328 if untracked_header in status_line:
329 untracked_header_seen = True
330 continue
331 if not untracked_header_seen:
332 continue
333 match = untracked_regex.match (status_line)
334 if match:
335 filename = match.group (1)
336 untracked.append (filename)
338 # Staged, unstaged, and renamed files
339 for status_line in status_lines:
340 if modified_header in status_line:
341 unstaged_header_seen = True
342 continue
343 match = modified_regex.match (status_line)
344 if match:
345 tag = match.group (0)
346 filename = status_line.replace (tag, '')
347 if unstaged_header_seen:
348 unstaged.append (filename)
349 else:
350 staged.append (filename)
351 continue
352 # Renamed files
353 match = renamed_regex.match (status_line)
354 if match:
355 oldname = match.group (2)
356 newname = match.group (3)
357 staged.append (oldname)
358 staged.append (newname)
360 return ( staged, unstaged, untracked )
362 def git_tag ():
363 return run_cmd ('git tag').splitlines()