7 from cStringIO
import StringIO
9 def git(*args
,**kwargs
):
10 """This is a convenience wrapper around run_cmd that
11 sets things up so that commands are run in the canonical
12 'git command [options] [args]' form."""
13 cmd
= 'git %s' % args
[0]
14 return utils
.run_cmd(cmd
, *args
[1:], **kwargs
)
16 class GitCommand(object):
17 """This class wraps this module so that arbitrary git commands
18 can be dynamically called at runtime."""
19 def __init__(self
, module
):
21 def __getattr__(self
, name
):
22 def git_cmd(*args
, **kwargs
):
23 return git(name
.replace('_','-'), *args
, **kwargs
)
25 return getattr(self
.module
, name
)
26 except AttributeError:
29 # At import we replace this module with a GitCommand singleton.
30 gitcmd
= GitCommand(sys
.modules
[__name__
])
31 sys
.modules
[__name__
] = gitcmd
34 #+-------------------------------------------------------------------------
35 #+ A regex for matching the output of git(log|rev-list) --pretty=oneline
36 REV_LIST_REGEX
= re
.compile('([0-9a-f]+)\W(.*)')
39 return ' '.join([ utils
.shell_quote(arg
) for arg
in argv
])
41 def add(to_add
, verbose
=True):
42 """Invokes 'git add' to index the filenames in to_add."""
44 return 'No files to add.'
45 return git('add', verbose
=verbose
, *to_add
)
47 def add_or_remove(to_process
):
48 """Invokes 'git add' to index the filenames in to_process that exist
49 and 'git rm' for those that do not exist."""
52 return 'No files to add or remove.'
57 for filename
in to_process
:
58 if os
.path
.exists(filename
):
59 to_add
.append(filename
)
63 if len(to_add
) == len(to_process
):
64 # to_process only contained unremoved files --
65 # short-circuit the removal checks
68 # Process files to remote
69 for filename
in to_process
:
70 if not os
.path
.exists(filename
):
71 to_remove
.append(filename
)
72 output
+ '\n\n' + git('rm',*to_remove
)
74 def apply(filename
, indexonly
=True, reverse
=False):
77 kwargs
['reverse'] = True
79 kwargs
['index'] = True
80 kwargs
['cached'] = True
81 argv
= ['apply', filename
]
82 return git(*argv
, **kwargs
)
84 def branch(name
=None, remote
=False, delete
=False):
86 return git('branch', name
, D
=True)
88 branches
= map(lambda x
: x
.lstrip('* '),
89 git('branch', r
=remote
).splitlines())
92 for branch
in branches
:
93 if branch
.endswith('/HEAD'):
95 remotes
.append(branch
)
99 def cat_file(objtype
, sha1
):
100 return git('cat-file', objtype
, sha1
, raw
=True)
102 def cherry_pick(revs
, commit
=False):
103 """Cherry-picks each revision into the current branch.
104 Returns a list of command output strings (1 per cherry pick)"""
106 if not revs
: return []
108 argv
= [ 'cherry-pick' ]
115 new_argv
= argv
+ [rev
]
116 cherries
.append(git(*new_argv
, **kwargs
))
118 return '\n'.join(cherries
)
121 return git('checkout', *args
)
123 def commit(msg
, amend
=False):
124 """Creates a git commit."""
126 if not msg
.endswith('\n'):
129 # Sure, this is a potential "security risk," but if someone
130 # is trying to intercept/re-write commit messages on your system,
131 # then you probably have bigger problems to worry about.
132 tmpfile
= utils
.get_tmp_filename()
138 # Create the commit message file
139 file = open(tmpfile
, 'w')
144 output
= git('commit', F
=tmpfile
, amend
=amend
)
147 return ('git commit -F %s --amend %s\n\n%s'
148 % ( tmpfile
, amend
, output
))
150 def create_branch(name
, base
, track
=False):
151 """Creates a branch starting from base. Pass track=True
152 to create a remote tracking branch."""
153 return git('branch', name
, base
, track
=track
)
155 def current_branch():
156 """Parses 'git branch' to find the current branch."""
157 branches
= git('branch').splitlines()
158 for branch
in branches
:
159 if branch
.startswith('* '):
160 return branch
.lstrip('* ')
161 return 'Detached HEAD'
163 def diff(commit
=None,filename
=None, color
=False,
164 cached
=True, with_diff_header
=False,
165 suppress_header
=True, reverse
=False):
166 "Invokes git diff on a filepath."
170 argv
.append('%s^..%s' % (commit
, commit
))
174 if type(filename
) is list:
175 argv
.extend(filename
)
177 argv
.append(filename
)
180 'patch-with-raw': True,
181 'unified': defaults
.DIFF_CONTEXT
,
191 diff_lines
= diff
.splitlines()
195 del_tag
= 'deleted file mode '
198 deleted
= cached
and not os
.path
.exists(filename
)
199 for line
in diff_lines
:
200 if not start
and '@@ ' in line
and ' @@' in line
:
202 if start
or(deleted
and del_tag
in line
):
203 output
.write(line
+ '\n')
207 elif not suppress_header
:
208 output
.write(line
+ '\n')
209 result
= output
.getvalue()
212 return('\n'.join(headers
), result
)
217 return git('diff', 'HEAD^',
218 unified
=defaults
.DIFF_CONTEXT
,
223 unified
=defaults
.DIFF_CONTEXT
,
227 def format_patch(revs
):
228 """writes patches named by revs to the "patches" directory."""
235 'patch-with-stat': True,
237 for idx
, rev
in enumerate(revs
):
238 real_idx
= idx
+ num_patches
239 kwargs
['start-number'] = real_idx
240 revarg
= '%s^..%s'%(rev
,rev
)
241 output
.append(git('format-patch', revarg
, **kwargs
))
242 num_patches
+= output
[-1].count('\n')
243 return '\n'.join(output
)
245 def config(key
=None, value
=None, local
=False, asdict
=False):
247 argv
= ['config', key
]
252 'global': local
is False,
253 'get': key
and value
is None,
258 return config_to_dict(git('config', **kwargs
).splitlines())
261 return git('config', key
, **kwargs
)
263 elif key
and value
is not None:
264 # git config category.key value
266 if type(value
) is bool:
267 # git uses "true" and "false"
268 strval
= strval
.lower()
269 return git('config', key
, strval
, **kwargs
)
271 msg
= "oops in git.config(key=%s,value=%s,local=%s,asdict=%s"
272 raise Exception(msg
% (key
, value
, local
, asdict
))
275 def config_to_dict(config_lines
):
276 """parses the lines from git config --list into a dictionary"""
279 for line
in config_lines
:
280 k
, v
= line
.split('=')
281 k
= k
.replace('.','_') # git -> model
282 if v
== 'true' or v
== 'false':
283 v
= bool(eval(v
.title()))
291 def log(oneline
=True, all
=False):
292 """Returns a pair of parallel arrays listing the revision sha1's
293 and commit summaries."""
296 kwargs
['pretty'] = 'oneline'
299 regex
= REV_LIST_REGEX
300 output
= git('log', all
=all
, **kwargs
)
301 for line
in output
.splitlines():
302 match
= regex
.match(line
)
304 revs
.append(match
.group(1))
305 summaries
.append(match
.group(2))
306 return( revs
, summaries
)
309 """git ls-files as a list"""
310 return git('ls-files').splitlines()
313 """Returns a list of(mode, type, sha1, path) tuples."""
314 lines
= git('ls-tree', rev
, r
=True).splitlines()
316 regex
= re
.compile('^(\d+)\W(\w+)\W(\w+)[ \t]+(.*)$')
318 match
= regex
.match(line
)
320 mode
= match
.group(1)
321 objtype
= match
.group(2)
322 sha1
= match
.group(3)
323 filename
= match
.group(4)
324 output
.append((mode
, objtype
, sha1
, filename
,) )
327 def push(remote
, local_branch
, remote_branch
, ffwd
=True, tags
=False):
329 branch_arg
= '%s:%s' % ( local_branch
, remote_branch
)
331 branch_arg
= '+%s:%s' % ( local_branch
, remote_branch
)
332 return git('push', remote
, branch_arg
, with_status
=True, tags
=tags
)
336 return 'No base branch specified to rebase.'
337 return git('rebase', newbase
)
340 return git('remote', without_stderr
=True, *args
).splitlines()
342 def remote_url(name
):
343 return config('remote.%s.url' % name
, local
=True)
345 def reset(to_unstage
):
346 """Use 'git reset' to unstage files from the index."""
348 return 'No files to reset.'
350 argv
= [ 'reset', '--' ]
351 argv
.extend(to_unstage
)
354 def rev_list_range(start
, end
):
355 argv
= [ 'rev-list', '--pretty=oneline', start
, end
]
356 raw_revs
= git(*argv
).splitlines()
358 for line
in raw_revs
:
359 match
= REV_LIST_REGEX
.match(line
)
361 rev_id
= match
.group(1)
362 summary
= match
.group(2)
363 revs
.append((rev_id
, summary
,) )
367 """RETURNS: A tuple of staged, unstaged and untracked file lists."""
369 MODIFIED_TAG
= '# Changed but not updated:'
370 UNTRACKED_TAG
= '# Untracked files:'
372 RGX_RENAMED
= re
.compile(
377 RGX_MODIFIED
= re
.compile(
391 current_dest
= staged
393 for status_line
in gitcmd
.status().splitlines():
394 if status_line
== MODIFIED_TAG
:
396 current_dest
= unstaged
399 elif status_line
== UNTRACKED_TAG
:
400 mode
= UNTRACKED_MODE
401 current_dest
= untracked
404 # Staged/unstaged modified/renamed/deleted files
405 if mode
== STAGED_MODE
or mode
== UNSTAGED_MODE
:
406 match
= RGX_MODIFIED
.match(status_line
)
409 filename
= status_line
.replace(tag
, '')
410 current_dest
.append(filename
)
412 match
= RGX_RENAMED
.match(status_line
)
414 oldname
= match
.group(2)
415 newname
= match
.group(3)
416 current_dest
.append(oldname
)
417 current_dest
.append(newname
)
420 elif mode
is UNTRACKED_MODE
:
421 if status_line
.startswith('#\t'):
422 current_dest
.append(status_line
[2:])
424 return( staged
, unstaged
, untracked
)