controllers: fix hang on exit bug
[git-cola.git] / python-git / git.py
bloba6ebe4e9626629cfbe50fe47f60335b34a46145c
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(to_export, revs, output='patches'):
389 """writes patches named by to_export to the output directory."""
391 outlines = []
393 cur_rev = to_export[0]
394 cur_master_idx = revs.index(cur_rev)
396 patches_to_export = [ [cur_rev] ]
397 patchset_idx = 0
399 for idx, rev in enumerate(to_export[1:]):
400 # Limit the search to the current neighborhood for efficiency
401 master_idx = revs[ cur_master_idx: ].index(rev)
402 master_idx += cur_master_idx
403 if master_idx == cur_master_idx + 1:
404 patches_to_export[ patchset_idx ].append(rev)
405 cur_master_idx += 1
406 continue
407 else:
408 patches_to_export.append([ rev ])
409 cur_master_idx = master_idx
410 patchset_idx += 1
412 for patchset in patches_to_export:
413 revarg = '%s^..%s' % (patchset[0], patchset[-1])
414 outlines.append(
415 gitcmd.format_patch(
416 revarg,
417 o=output,
418 n=len(patchset) > 1,
419 thread=True,
420 patch_with_stat=True
424 return '\n'.join(outlines)
426 def get_merge_message():
427 return gitcmd.fmt_merge_msg('--file', git_repo_path('FETCH_HEAD'))
429 def config_set(key=None, value=None, local=True):
430 if key and value is not None:
431 # git config category.key value
432 strval = str(value)
433 if type(value) is bool:
434 # git uses "true" and "false"
435 strval = strval.lower()
436 if local:
437 argv = [ key, strval ]
438 else:
439 argv = [ '--global', key, strval ]
440 return gitcmd.config(*argv)
441 else:
442 msg = "oops in git.config_set(key=%s,value=%s,local=%s"
443 raise Exception(msg % (key, value, local))
445 def config_dict(local=True):
446 if local:
447 argv = [ '--list' ]
448 else:
449 argv = ['--global', '--list' ]
450 return config_to_dict(
451 gitcmd.config(*argv).splitlines())
453 def config_to_dict(config_lines):
454 """parses the lines from git config --list into a dictionary"""
456 newdict = {}
457 for line in config_lines:
458 k, v = line.split('=', 1)
459 k = k.replace('.','_') # git -> model
460 if v == 'true' or v == 'false':
461 v = bool(eval(v.title()))
462 try:
463 v = int(eval(v))
464 except:
465 pass
466 newdict[k]=v
467 return newdict
469 def log_helper(all=False):
470 """Returns a pair of parallel arrays listing the revision sha1's
471 and commit summaries."""
472 revs = []
473 summaries = []
474 regex = REV_LIST_REGEX
475 output = gitcmd.log(pretty='oneline', all=all)
476 for line in output.splitlines():
477 match = regex.match(line)
478 if match:
479 revs.append(match.group(1))
480 summaries.append(match.group(2))
481 return( revs, summaries )
483 def parse_ls_tree(rev):
484 """Returns a list of(mode, type, sha1, path) tuples."""
485 lines = gitcmd.ls_tree(rev, r=True).splitlines()
486 output = []
487 regex = re.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
488 for line in lines:
489 match = regex.match(line)
490 if match:
491 mode = match.group(1)
492 objtype = match.group(2)
493 sha1 = match.group(3)
494 filename = match.group(4)
495 output.append((mode, objtype, sha1, filename,) )
496 return output
498 def push_helper(remote, local_branch, remote_branch, ffwd=True, tags=False):
499 if ffwd:
500 branch_arg = '%s:%s' % ( local_branch, remote_branch )
501 else:
502 branch_arg = '+%s:%s' % ( local_branch, remote_branch )
503 return gitcmd.push(remote, branch_arg, with_status=True, tags=tags)
505 def remote_url(name):
506 return gitcmd.config('remote.%s.url' % name, get=True)
508 def rev_list_range(start, end):
509 range = '%s..%s' % ( start, end )
510 raw_revs = gitcmd.rev_list(range, pretty='oneline')
511 return parse_rev_list(raw_revs)
513 def git_repo_path(*subpaths):
514 paths = [ gitcmd.rev_parse(git_dir=True) ]
515 paths.extend(subpaths)
516 return os.path.realpath(os.path.join(*paths))
518 def get_merge_message_path():
519 for file in ('MERGE_MSG', 'SQUASH_MSG'):
520 path = git_repo_path(file)
521 if os.path.exists(path):
522 return path
523 return None
525 def reset_helper(*args, **kwargs):
526 return gitcmd.reset('--', *args, **kwargs)
528 def parse_rev_list(raw_revs):
529 revs = []
530 for line in raw_revs.splitlines():
531 match = REV_LIST_REGEX.match(line)
532 if match:
533 rev_id = match.group(1)
534 summary = match.group(2)
535 revs.append((rev_id, summary,) )
536 return revs
538 def parse_status():
539 """RETURNS: A tuple of staged, unstaged and untracked file lists.
541 def eval_path(path):
542 """handles quoted paths."""
543 if path.startswith('"') and path.endswith('"'):
544 return eval(path)
545 else:
546 return path
548 MODIFIED_TAG = '# Changed but not updated:'
549 UNTRACKED_TAG = '# Untracked files:'
550 RGX_RENAMED = re.compile(
551 '(#\trenamed:\s+)'
552 '(.*?)\s->\s(.*)'
554 RGX_MODIFIED = re.compile(
555 '(#\tmodified:\s+'
556 '|#\tnew file:\s+'
557 '|#\tdeleted:\s+)'
559 staged = []
560 unstaged = []
561 untracked = []
563 STAGED_MODE = 0
564 UNSTAGED_MODE = 1
565 UNTRACKED_MODE = 2
567 current_dest = staged
568 mode = STAGED_MODE
570 for status_line in gitcmd.status().splitlines():
571 if status_line == MODIFIED_TAG:
572 mode = UNSTAGED_MODE
573 current_dest = unstaged
574 continue
575 elif status_line == UNTRACKED_TAG:
576 mode = UNTRACKED_MODE
577 current_dest = untracked
578 continue
579 # Staged/unstaged modified/renamed/deleted files
580 if mode is STAGED_MODE or mode is UNSTAGED_MODE:
581 match = RGX_MODIFIED.match(status_line)
582 if match:
583 tag = match.group(0)
584 filename = status_line.replace(tag, '')
585 current_dest.append(eval_path(filename))
586 continue
587 match = RGX_RENAMED.match(status_line)
588 if match:
589 oldname = match.group(2)
590 newname = match.group(3)
591 current_dest.append(eval_path(oldname))
592 current_dest.append(eval_path(newname))
593 continue
594 # Untracked files
595 elif mode is UNTRACKED_MODE:
596 if status_line.startswith('#\t'):
597 current_dest.append(eval_path(status_line[2:]))
599 return( staged, unstaged, untracked )
601 # Must be executed after all functions are defined
602 gitcmd.setup_commands()