git.py: add the "rest" of the git commands
[git-cola.git] / python-git / git.py
blob6bffe9632bf39e589cbea69486ece58aa91369fe
1 import subprocess
2 import os
3 import re
4 import sys
5 import time
6 import types
7 from cStringIO import StringIO
8 DIFF_CONTEXT = 3
10 def set_diff_context(ctxt):
11 global DIFF_CONTEXT
12 DIFF_CONTEXT = ctxt
14 def get_tmp_filename():
15 # Allow TMPDIR/TMP with a fallback to /tmp
16 env = os.environ
17 return os.path.join(env.get('TMP', env.get('TMPDIR', '/tmp')),
18 '.git.%s.%s' % ( os.getpid(), time.time()))
20 def run_cmd(cmd, *args, **kwargs):
21 """
22 Returns an array of strings from the command's output.
24 DEFAULTS:
25 raw=False
27 Passing raw=True prevents the output from being striped.
29 with_status = False
31 Passing with_status=True returns tuple(status,output)
32 instead of just the command's output.
34 run_command("git foo", bar, buzz,
35 baz=value, bar=True, q=True, f='foo')
37 Implies:
38 argv = ["git", "foo",
39 "-q", "-ffoo",
40 "--bar", "--baz=value",
41 "bar","buzz" ]
42 """
44 def pop_key(d, key):
45 val = d.get(key)
46 try: del d[key]
47 except: pass
48 return val
49 raw = pop_key(kwargs, 'raw')
50 with_status = pop_key(kwargs,'with_status')
51 with_stderr = not pop_key(kwargs,'without_stderr')
52 cwd = os.getcwd()
54 kwarglist = []
55 for k,v in kwargs.iteritems():
56 if len(k) > 1:
57 k = k.replace('_','-')
58 if v is True:
59 kwarglist.append("--%s" % k)
60 elif v is not None and type(v) is not bool:
61 kwarglist.append("--%s=%s" % (k,v))
62 else:
63 if v is True:
64 kwarglist.append("-%s" % k)
65 elif v is not None and type(v) is not bool:
66 kwarglist.append("-%s" % k)
67 kwarglist.append(str(v))
68 # Handle cmd as either a string or an argv list
69 if type(cmd) is str:
70 # we only call run_cmd(str) with str='git command'
71 # or other simple commands
72 cmd = cmd.split(' ')
73 cmd += kwarglist
74 cmd += tuple(args)
75 else:
76 cmd = tuple(cmd + kwarglist + list(args))
78 stderr = None
79 if with_stderr:
80 stderr = subprocess.STDOUT
81 # start the process
82 proc = subprocess.Popen(cmd, cwd=cwd,
83 stdout=subprocess.PIPE,
84 stderr=stderr,
85 stdin=None)
86 # Wait for the process to return
87 output, err = proc.communicate()
88 # conveniently strip off trailing newlines
89 if not raw:
90 output = output.rstrip()
91 if err:
92 raise RuntimeError("%s return exit status %d"
93 % ( str(cmd), err ))
94 if with_status:
95 return (err, output)
96 else:
97 return output
99 # union of functions in this file and dynamic functions
100 # defined in the git command string list below
101 def git(*args,**kwargs):
102 """This is a convenience wrapper around run_cmd that
103 sets things up so that commands are run in the canonical
104 'git command [options] [args]' form."""
105 cmd = 'git %s' % args[0]
106 return run_cmd(cmd, *args[1:], **kwargs)
108 class GitCommand(object):
109 """This class wraps this module so that arbitrary git commands
110 can be dynamically called at runtime."""
111 def __init__(self, module):
112 self.module = module
113 self.commands = {}
114 # This creates git.foo() methods dynamically for each of the
115 # following names at import-time.
116 for cmd in """
119 annotate
120 apply
121 archive
122 archive_recursive
123 bisect
124 blame
125 branch
126 bundle
127 checkout
128 checkout_index
129 cherry
130 cherry_pick
131 citool
132 clean
133 clone
134 commit
135 config
136 count_objects
137 describe
138 diff
139 fast_export
140 fetch
141 filter_branch
142 format_patch
143 fsck
145 get_tar_commit_id
146 grep
148 hard_repack
149 imap_send
150 init
151 instaweb
153 lost_found
154 ls_files
155 ls_remote
156 ls_tree
157 merge
158 mergetool
160 name_rev
161 pull
162 push
163 read_tree
164 rebase
165 relink
166 remote
167 repack
168 request_pull
169 reset
170 revert
171 rev_list
173 send_email
174 shortlog
175 show
176 show_branch
177 show_ref
178 stash
179 status
180 submodule
184 verify_pack
185 whatchanged
186 """.split(): getattr(self, cmd)
188 def setup_commands(self):
189 # Import the functions from the module
190 for name, val in self.module.__dict__.iteritems():
191 if type(val) is types.FunctionType:
192 setattr(self, name, val)
193 # Import dynamic functions and those from the module
194 # functions into self.commands
195 for name, val in self.__dict__.iteritems():
196 if type(val) is types.FunctionType:
197 self.commands[name] = val
199 def __getattr__(self, cmd):
200 if hasattr(self.module, cmd):
201 value = getattr(self.module, cmd)
202 setattr(self, cmd, value)
203 return value
204 def git_cmd(*args, **kwargs):
205 """Runs "git <cmd> [options] [args]"
206 The output is returned as a string.
207 Pass with_stauts=True to merge stderr's into stdout.
208 Pass raw=True to avoid stripping git's output.
209 Finally, pass with_status=True to
210 return a (status, output) tuple."""
211 return git(cmd.replace('_','-'), *args, **kwargs)
212 setattr(self, cmd, git_cmd)
213 return git_cmd
215 # core git wrapper for use in this module
216 gitcmd = GitCommand(sys.modules[__name__])
217 sys.modules[__name__] = gitcmd
219 #+-------------------------------------------------------------------------
220 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
221 REV_LIST_REGEX = re.compile('([0-9a-f]+)\W(.*)')
223 def abort_merge():
224 # Reset the worktree
225 output = gitcmd.read_tree("HEAD", reset=True, u=True, v=True)
226 # remove MERGE_HEAD
227 merge_head = git_repo_path('MERGE_HEAD')
228 if os.path.exists(merge_head):
229 os.unlink(merge_head)
230 # remove MERGE_MESSAGE, etc.
231 merge_msg_path = get_merge_message_path()
232 while merge_msg_path is not None:
233 os.unlink(merge_msg_path)
234 merge_msg_path = get_merge_message_path()
236 def add_or_remove(*to_process):
237 """Invokes 'git add' to index the filenames in to_process that exist
238 and 'git rm' for those that do not exist."""
240 if not to_process:
241 return 'No files to add or remove.'
243 to_add = []
244 to_remove = []
246 for filename in to_process:
247 if os.path.exists(filename):
248 to_add.append(filename)
250 output = gitcmd.add(verbose=True, *to_add)
252 if len(to_add) == len(to_process):
253 # to_process only contained unremoved files --
254 # short-circuit the removal checks
255 return output
257 # Process files to remote
258 for filename in to_process:
259 if not os.path.exists(filename):
260 to_remove.append(filename)
261 output + '\n\n' + gitcmd.rm(*to_remove)
263 def branch_list(remote=False):
264 branches = map(lambda x: x.lstrip('* '),
265 gitcmd.branch(r=remote).splitlines())
266 if remote:
267 remotes = []
268 for branch in branches:
269 if branch.endswith('/HEAD'):
270 continue
271 remotes.append(branch)
272 return remotes
273 return branches
275 def cherry_pick_list(revs, **kwargs):
276 """Cherry-picks each revision into the current branch.
277 Returns a list of command output strings (1 per cherry pick)"""
278 if not revs:
279 return []
280 cherries = []
281 for rev in revs:
282 cherries.append(gitcmd.cherry_pick(rev, **kwargs))
283 return '\n'.join(cherries)
285 def commit_with_msg(msg, amend=False):
286 """Creates a git commit."""
288 if not msg.endswith('\n'):
289 msg += '\n'
290 # Sure, this is a potential "security risk," but if someone
291 # is trying to intercept/re-write commit messages on your system,
292 # then you probably have bigger problems to worry about.
293 tmpfile = get_tmp_filename()
294 kwargs = {
295 'F': tmpfile,
296 'amend': amend,
298 # Create the commit message file
299 file = open(tmpfile, 'w')
300 file.write(msg)
301 file.close()
303 # Run 'git commit'
304 output = gitcmd.commit(F=tmpfile, amend=amend)
305 os.unlink(tmpfile)
307 return ('git commit -F %s --amend %s\n\n%s'
308 % ( tmpfile, amend, output ))
310 def create_branch(name, base, track=False):
311 """Creates a branch starting from base. Pass track=True
312 to create a remote tracking branch."""
313 return gitcmd.branch(name, base, track=track)
315 def current_branch():
316 """Parses 'git branch' to find the current branch."""
318 branches = gitcmd.branch().splitlines()
319 for branch in branches:
320 if branch.startswith('* '):
321 return branch.lstrip('* ')
322 return 'Detached HEAD'
324 def diff_helper(commit=None,
325 filename=None,
326 color=False,
327 cached=True,
328 with_diff_header=False,
329 suppress_header=True,
330 reverse=False):
331 "Invokes git diff on a filepath."
333 argv = []
334 if commit:
335 argv.append('%s^..%s' % (commit, commit))
337 if filename:
338 argv.append('--')
339 if type(filename) is list:
340 argv.extend(filename)
341 else:
342 argv.append(filename)
344 diff = gitcmd.diff(
345 R=reverse,
346 color=color,
347 cached=cached,
348 patch_with_raw=True,
349 unified=DIFF_CONTEXT,
350 *argv
351 ).splitlines()
353 output = StringIO()
354 start = False
355 del_tag = 'deleted file mode '
357 headers = []
358 deleted = cached and not os.path.exists(filename)
359 for line in diff:
360 if not start and '@@ ' in line and ' @@' in line:
361 start = True
362 if start or(deleted and del_tag in line):
363 output.write(line + '\n')
364 else:
365 if with_diff_header:
366 headers.append(line)
367 elif not suppress_header:
368 output.write(line + '\n')
369 result = output.getvalue()
370 output.close()
371 if with_diff_header:
372 return('\n'.join(headers), result)
373 else:
374 return result
376 def diffstat():
377 return gitcmd.diff(
378 'HEAD^',
379 unified=DIFF_CONTEXT,
380 stat=True)
382 def diffindex():
383 return gitcmd.diff(
384 unified=DIFF_CONTEXT,
385 stat=True,
386 cached=True)
388 def format_patch_helper(output='patches', *revs):
389 """writes patches named by revs to the output directory."""
390 num_patches = 1
391 output = []
392 for idx, rev in enumerate(revs):
393 real_idx = idx + num_patches
394 revarg = '%s^..%s' % (rev,rev)
395 output.append(
396 gitcmd.format_patch(
397 revarg,
398 o=output,
399 start_number=real_idx,
400 n=len(revs) > 1,
401 thread=True,
402 patch_with_stat=True
405 num_patches += output[-1].count('\n')
406 return '\n'.join(output)
408 def get_merge_message():
409 return gitcmd.fmt_merge_msg('--file', git_repo_path('FETCH_HEAD'))
411 def config_set(key=None, value=None, local=True):
412 if key and value is not None:
413 # git config category.key value
414 strval = str(value)
415 if type(value) is bool:
416 # git uses "true" and "false"
417 strval = strval.lower()
418 if local:
419 argv = [ key, strval ]
420 else:
421 argv = [ '--global', key, strval ]
422 return gitcmd.config(*argv)
423 else:
424 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
425 raise Exception(msg % (key, value, local))
427 def config_dict(local=True):
428 if local:
429 argv = [ '--list' ]
430 else:
431 argv = ['--global', '--list' ]
432 return config_to_dict(
433 gitcmd.config(*argv).splitlines())
435 def config_to_dict(config_lines):
436 """parses the lines from git config --list into a dictionary"""
438 newdict = {}
439 for line in config_lines:
440 k, v = line.split('=', 1)
441 k = k.replace('.','_') # git -> model
442 if v == 'true' or v == 'false':
443 v = bool(eval(v.title()))
444 try:
445 v = int(eval(v))
446 except:
447 pass
448 newdict[k]=v
449 return newdict
451 def log_helper(all=False):
452 """Returns a pair of parallel arrays listing the revision sha1's
453 and commit summaries."""
454 revs = []
455 summaries = []
456 regex = REV_LIST_REGEX
457 output = gitcmd.log(pretty='oneline', all=all)
458 for line in output.splitlines():
459 match = regex.match(line)
460 if match:
461 revs.append(match.group(1))
462 summaries.append(match.group(2))
463 return( revs, summaries )
465 def parse_ls_tree(rev):
466 """Returns a list of(mode, type, sha1, path) tuples."""
467 lines = gitcmd.ls_tree(rev, r=True).splitlines()
468 output = []
469 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
470 for line in lines:
471 match = regex.match(line)
472 if match:
473 mode = match.group(1)
474 objtype = match.group(2)
475 sha1 = match.group(3)
476 filename = match.group(4)
477 output.append((mode, objtype, sha1, filename,) )
478 return output
480 def push_helper(remote, local_branch, remote_branch, ffwd=True, tags=False):
481 if ffwd:
482 branch_arg = '%s:%s' % ( local_branch, remote_branch )
483 else:
484 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
485 return gitcmd.push(remote, branch_arg, with_status=True, tags=tags)
487 def remote_url(name):
488 return gitcmd.config('remote.%s.url' % name, get=True)
490 def rev_list_range(start, end):
491 range = '%s..%s' % ( start, end )
492 raw_revs = gitcmd.rev_list(range, pretty='oneline')
493 return parse_rev_list(raw_revs)
495 def git_repo_path(*subpaths):
496 paths = [ gitcmd.rev_parse(git_dir=True) ]
497 paths.extend(subpaths)
498 return os.path.realpath(os.path.join(*paths))
500 def get_merge_message_path():
501 for file in ('MERGE_MSG', 'SQUASH_MSG'):
502 path = git_repo_path(file)
503 if os.path.exists(path):
504 return path
505 return None
507 def reset_helper(*args, **kwargs):
508 return gitcmd.reset('--', *args, **kwargs)
510 def parse_rev_list(raw_revs):
511 revs = []
512 for line in raw_revs.splitlines():
513 match = REV_LIST_REGEX.match(line)
514 if match:
515 rev_id = match.group(1)
516 summary = match.group(2)
517 revs.append((rev_id, summary,) )
518 return revs
520 def parse_status():
521 """RETURNS: A tuple of staged, unstaged and untracked file lists.
523 def eval_path(path):
524 """handles quoted paths."""
525 if path.startswith('"') and path.endswith('"'):
526 return eval(path)
527 else:
528 return path
530 MODIFIED_TAG = '# Changed but not updated:'
531 UNTRACKED_TAG = '# Untracked files:'
532 RGX_RENAMED = re.compile(
533 '(#\trenamed:\s+)'
534 '(.*?)\s->\s(.*)'
536 RGX_MODIFIED = re.compile(
537 '(#\tmodified:\s+'
538 '|#\tnew file:\s+'
539 '|#\tdeleted:\s+)'
541 staged = []
542 unstaged = []
543 untracked = []
545 STAGED_MODE = 0
546 UNSTAGED_MODE = 1
547 UNTRACKED_MODE = 2
549 current_dest = staged
550 mode = STAGED_MODE
552 for status_line in gitcmd.status().splitlines():
553 if status_line == MODIFIED_TAG:
554 mode = UNSTAGED_MODE
555 current_dest = unstaged
556 continue
557 elif status_line == UNTRACKED_TAG:
558 mode = UNTRACKED_MODE
559 current_dest = untracked
560 continue
561 # Staged/unstaged modified/renamed/deleted files
562 if mode is STAGED_MODE or mode is UNSTAGED_MODE:
563 match = RGX_MODIFIED.match(status_line)
564 if match:
565 tag = match.group(0)
566 filename = status_line.replace(tag, '')
567 current_dest.append(eval_path(filename))
568 continue
569 match = RGX_RENAMED.match(status_line)
570 if match:
571 oldname = match.group(2)
572 newname = match.group(3)
573 current_dest.append(eval_path(oldname))
574 current_dest.append(eval_path(newname))
575 continue
576 # Untracked files
577 elif mode is UNTRACKED_MODE:
578 if status_line.startswith('#\t'):
579 current_dest.append(eval_path(status_line[2:]))
581 return( staged, unstaged, untracked )
583 # Must be executed after all functions are defined
584 gitcmd.setup_commands()