chiark / gitweb /
4bd17b1c5072a8cbed12b6ea09bee5cc8a50eba4
[stgit] / stgit / git.py
1 """Python GIT interface
2 """
3
4 __copyright__ = """
5 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
6
7 This program is free software; you can redistribute it and/or modify
8 it under the terms of the GNU General Public License version 2 as
9 published by the Free Software Foundation.
10
11 This program is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with this program; if not, write to the Free Software
18 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19 """
20
21 import sys, os, re, gitmergeonefile
22 from shutil import copyfile
23
24 from stgit.exception import *
25 from stgit import basedir
26 from stgit.utils import *
27 from stgit.out import *
28 from stgit.run import *
29 from stgit.config import config
30
31 # git exception class
32 class GitException(StgException):
33     pass
34
35 # When a subprocess has a problem, we want the exception to be a
36 # subclass of GitException.
37 class GitRunException(GitException):
38     pass
39 class GRun(Run):
40     exc = GitRunException
41     def __init__(self, *cmd):
42         """Initialise the Run object and insert the 'git' command name.
43         """
44         Run.__init__(self, 'git', *cmd)
45
46
47 #
48 # Classes
49 #
50
51 class Person:
52     """An author, committer, etc."""
53     def __init__(self, name = None, email = None, date = '',
54                  desc = None):
55         self.name = self.email = self.date = None
56         if name or email or date:
57             assert not desc
58             self.name = name
59             self.email = email
60             self.date = date
61         elif desc:
62             assert not (name or email or date)
63             def parse_desc(s):
64                 m = re.match(r'^(.+)<(.+)>(.*)$', s)
65                 assert m
66                 return [x.strip() or None for x in m.groups()]
67             self.name, self.email, self.date = parse_desc(desc)
68     def set_name(self, val):
69         if val:
70             self.name = val
71     def set_email(self, val):
72         if val:
73             self.email = val
74     def set_date(self, val):
75         if val:
76             self.date = val
77     def __str__(self):
78         if self.name and self.email:
79             return '%s <%s>' % (self.name, self.email)
80         else:
81             raise GitException, 'not enough identity data'
82
83 class Commit:
84     """Handle the commit objects
85     """
86     def __init__(self, id_hash):
87         self.__id_hash = id_hash
88
89         lines = GRun('cat-file', 'commit', id_hash).output_lines()
90         for i in range(len(lines)):
91             line = lines[i]
92             if not line:
93                 break # we've seen all the header fields
94             key, val = line.split(' ', 1)
95             if key == 'tree':
96                 self.__tree = val
97             elif key == 'author':
98                 self.__author = val
99             elif key == 'committer':
100                 self.__committer = val
101             else:
102                 pass # ignore other headers
103         self.__log = '\n'.join(lines[i+1:])
104
105     def get_id_hash(self):
106         return self.__id_hash
107
108     def get_tree(self):
109         return self.__tree
110
111     def get_parent(self):
112         parents = self.get_parents()
113         if parents:
114             return parents[0]
115         else:
116             return None
117
118     def get_parents(self):
119         return GRun('rev-list', '--parents', '--max-count=1', self.__id_hash
120                     ).output_one_line().split()[1:]
121
122     def get_author(self):
123         return self.__author
124
125     def get_committer(self):
126         return self.__committer
127
128     def get_log(self):
129         return self.__log
130
131     def __str__(self):
132         return self.get_id_hash()
133
134 # dictionary of Commit objects, used to avoid multiple calls to git
135 __commits = dict()
136
137 #
138 # Functions
139 #
140
141 def get_commit(id_hash):
142     """Commit objects factory. Save/look-up them in the __commits
143     dictionary
144     """
145     global __commits
146
147     if id_hash in __commits:
148         return __commits[id_hash]
149     else:
150         commit = Commit(id_hash)
151         __commits[id_hash] = commit
152         return commit
153
154 def get_conflicts():
155     """Return the list of file conflicts
156     """
157     conflicts_file = os.path.join(basedir.get(), 'conflicts')
158     if os.path.isfile(conflicts_file):
159         f = file(conflicts_file)
160         names = [line.strip() for line in f.readlines()]
161         f.close()
162         return names
163     else:
164         return None
165
166 def exclude_files():
167     files = [os.path.join(basedir.get(), 'info', 'exclude')]
168     user_exclude = config.get('core.excludesfile')
169     if user_exclude:
170         files.append(user_exclude)
171     return files
172
173 def ls_files(files, tree = None, full_name = True):
174     """Return the files known to GIT or raise an error otherwise. It also
175     converts the file to the full path relative the the .git directory.
176     """
177     if not files:
178         return []
179
180     args = []
181     if tree:
182         args.append('--with-tree=%s' % tree)
183     if full_name:
184         args.append('--full-name')
185     args.append('--')
186     args.extend(files)
187     try:
188         return GRun('ls-files', '--error-unmatch', *args).output_lines()
189     except GitRunException:
190         # just hide the details of the 'git ls-files' command we use
191         raise GitException, \
192             'Some of the given paths are either missing or not known to GIT'
193
194 def tree_status(files = None, tree_id = 'HEAD', unknown = False,
195                   noexclude = True, verbose = False, diff_flags = []):
196     """Get the status of all changed files, or of a selected set of
197     files. Returns a list of pairs - (status, filename).
198
199     If 'not files', it will check all files, and optionally all
200     unknown files.  If 'files' is a list, it will only check the files
201     in the list.
202     """
203     assert not files or not unknown
204
205     if verbose:
206         out.start('Checking for changes in the working directory')
207
208     refresh_index()
209
210     cache_files = []
211
212     # unknown files
213     if unknown:
214         cmd = ['ls-files', '-z', '--others', '--directory',
215                '--no-empty-directory']
216         if not noexclude:
217             cmd += ['--exclude=%s' % s for s in
218                     ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
219             cmd += ['--exclude-per-directory=.gitignore']
220             cmd += ['--exclude-from=%s' % fn
221                     for fn in exclude_files()
222                     if os.path.exists(fn)]
223
224         lines = GRun(*cmd).raw_output().split('\0')
225         cache_files += [('?', line) for line in lines if line]
226
227     # conflicted files
228     conflicts = get_conflicts()
229     if not conflicts:
230         conflicts = []
231     cache_files += [('C', filename) for filename in conflicts
232                     if not files or filename in files]
233     reported_files = set(conflicts)
234
235     # files in the index
236     args = diff_flags + [tree_id]
237     if files:
238         args += ['--'] + files
239     for line in GRun('diff-index', *args).output_lines():
240         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
241         if fs[1] not in reported_files:
242             cache_files.append(fs)
243             reported_files.add(fs[1])
244
245     # files in the index but changed on (or removed from) disk
246     args = list(diff_flags)
247     if files:
248         args += ['--'] + files
249     for line in GRun('diff-files', *args).output_lines():
250         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
251         if fs[1] not in reported_files:
252             cache_files.append(fs)
253             reported_files.add(fs[1])
254
255     if verbose:
256         out.done()
257
258     return cache_files
259
260 def local_changes(verbose = True):
261     """Return true if there are local changes in the tree
262     """
263     return len(tree_status(verbose = verbose)) != 0
264
265 def get_heads():
266     heads = []
267     hr = re.compile(r'^[0-9a-f]{40} refs/heads/(.+)$')
268     for line in GRun('show-ref', '--heads').output_lines():
269         m = hr.match(line)
270         heads.append(m.group(1))
271     return heads
272
273 # HEAD value cached
274 __head = None
275
276 def get_head():
277     """Verifies the HEAD and returns the SHA1 id that represents it
278     """
279     global __head
280
281     if not __head:
282         __head = rev_parse('HEAD')
283     return __head
284
285 class DetachedHeadException(GitException):
286     def __init__(self):
287         GitException.__init__(self, 'Not on any branch')
288
289 def get_head_file():
290     """Return the name of the file pointed to by the HEAD symref.
291     Throw an exception if HEAD is detached."""
292     try:
293         return strip_prefix(
294             'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD'
295                                 ).output_one_line())
296     except GitRunException:
297         raise DetachedHeadException()
298
299 def set_head_file(ref):
300     """Resets HEAD to point to a new ref
301     """
302     # head cache flushing is needed since we might have a different value
303     # in the new head
304     __clear_head_cache()
305     try:
306         GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref).run()
307     except GitRunException:
308         raise GitException, 'Could not set head to "%s"' % ref
309
310 def set_ref(ref, val):
311     """Point ref at a new commit object."""
312     try:
313         GRun('update-ref', ref, val).run()
314     except GitRunException:
315         raise GitException, 'Could not update %s to "%s".' % (ref, val)
316
317 def set_branch(branch, val):
318     set_ref('refs/heads/%s' % branch, val)
319
320 def __set_head(val):
321     """Sets the HEAD value
322     """
323     global __head
324
325     if not __head or __head != val:
326         set_ref('HEAD', val)
327         __head = val
328
329     # only allow SHA1 hashes
330     assert(len(__head) == 40)
331
332 def __clear_head_cache():
333     """Sets the __head to None so that a re-read is forced
334     """
335     global __head
336
337     __head = None
338
339 def refresh_index():
340     """Refresh index with stat() information from the working directory.
341     """
342     GRun('update-index', '-q', '--unmerged', '--refresh').run()
343
344 def rev_parse(git_id):
345     """Parse the string and return a verified SHA1 id
346     """
347     try:
348         return GRun('rev-parse', '--verify', git_id
349                     ).discard_stderr().output_one_line()
350     except GitRunException:
351         raise GitException, 'Unknown revision: %s' % git_id
352
353 def ref_exists(ref):
354     try:
355         rev_parse(ref)
356         return True
357     except GitException:
358         return False
359
360 def branch_exists(branch):
361     return ref_exists('refs/heads/%s' % branch)
362
363 def create_branch(new_branch, tree_id = None):
364     """Create a new branch in the git repository
365     """
366     if branch_exists(new_branch):
367         raise GitException, 'Branch "%s" already exists' % new_branch
368
369     current_head_file = get_head_file()
370     current_head = get_head()
371     set_head_file(new_branch)
372     __set_head(current_head)
373
374     # a checkout isn't needed if new branch points to the current head
375     if tree_id:
376         try:
377             switch(tree_id)
378         except GitException:
379             # Tree switching failed. Revert the head file
380             set_head_file(current_head_file)
381             delete_branch(new_branch)
382             raise
383
384     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
385         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
386
387 def switch_branch(new_branch):
388     """Switch to a git branch
389     """
390     global __head
391
392     if not branch_exists(new_branch):
393         raise GitException, 'Branch "%s" does not exist' % new_branch
394
395     tree_id = rev_parse('refs/heads/%s^{commit}' % new_branch)
396     if tree_id != get_head():
397         refresh_index()
398         try:
399             GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
400         except GitRunException:
401             raise GitException, 'read-tree failed (local changes maybe?)'
402         __head = tree_id
403     set_head_file(new_branch)
404
405     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
406         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
407
408 def delete_ref(ref):
409     if not ref_exists(ref):
410         raise GitException, '%s does not exist' % ref
411     sha1 = GRun('show-ref', '-s', ref).output_one_line()
412     try:
413         GRun('update-ref', '-d', ref, sha1).run()
414     except GitRunException:
415         raise GitException, 'Failed to delete ref %s' % ref
416
417 def delete_branch(name):
418     delete_ref('refs/heads/%s' % name)
419
420 def rename_ref(from_ref, to_ref):
421     if not ref_exists(from_ref):
422         raise GitException, '"%s" does not exist' % from_ref
423     if ref_exists(to_ref):
424         raise GitException, '"%s" already exists' % to_ref
425
426     sha1 = GRun('show-ref', '-s', from_ref).output_one_line()
427     try:
428         GRun('update-ref', to_ref, sha1, '0'*40).run()
429     except GitRunException:
430         raise GitException, 'Failed to create new ref %s' % to_ref
431     try:
432         GRun('update-ref', '-d', from_ref, sha1).run()
433     except GitRunException:
434         raise GitException, 'Failed to delete ref %s' % from_ref
435
436 def rename_branch(from_name, to_name):
437     """Rename a git branch."""
438     rename_ref('refs/heads/%s' % from_name, 'refs/heads/%s' % to_name)
439     try:
440         if get_head_file() == from_name:
441             set_head_file(to_name)
442     except DetachedHeadException:
443         pass # detached HEAD, so the renamee can't be the current branch
444     reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
445     if os.path.exists(reflog_dir) \
446            and os.path.exists(os.path.join(reflog_dir, from_name)):
447         rename(reflog_dir, from_name, to_name)
448
449 def add(names):
450     """Add the files or recursively add the directory contents
451     """
452     # generate the file list
453     files = []
454     for i in names:
455         if not os.path.exists(i):
456             raise GitException, 'Unknown file or directory: %s' % i
457
458         if os.path.isdir(i):
459             # recursive search. We only add files
460             for root, dirs, local_files in os.walk(i):
461                 for name in [os.path.join(root, f) for f in local_files]:
462                     if os.path.isfile(name):
463                         files.append(os.path.normpath(name))
464         elif os.path.isfile(i):
465             files.append(os.path.normpath(i))
466         else:
467             raise GitException, '%s is not a file or directory' % i
468
469     if files:
470         try:
471             GRun('update-index', '--add', '--').xargs(files)
472         except GitRunException:
473             raise GitException, 'Unable to add file'
474
475 def __copy_single(source, target, target2=''):
476     """Copy file or dir named 'source' to name target+target2"""
477
478     # "source" (file or dir) must match one or more git-controlled file
479     realfiles = GRun('ls-files', source).output_lines()
480     if len(realfiles) == 0:
481         raise GitException, '"%s" matches no git-controled files' % source
482
483     if os.path.isdir(source):
484         # physically copy the files, and record them to add them in one run
485         newfiles = []
486         re_string='^'+source+'/(.*)$'
487         prefix_regexp = re.compile(re_string)
488         for f in [f.strip() for f in realfiles]:
489             m = prefix_regexp.match(f)
490             if not m:
491                 raise Exception, '"%s" does not match "%s"' % (f, re_string)
492             newname = target+target2+'/'+m.group(1)
493             if not os.path.exists(os.path.dirname(newname)):
494                 os.makedirs(os.path.dirname(newname))
495             copyfile(f, newname)
496             newfiles.append(newname)
497
498         add(newfiles)
499     else: # files, symlinks, ...
500         newname = target+target2
501         copyfile(source, newname)
502         add([newname])
503
504
505 def copy(filespecs, target):
506     if os.path.isdir(target):
507         # target is a directory: copy each entry on the command line,
508         # with the same name, into the target
509         target = target.rstrip('/')
510         
511         # first, check that none of the children of the target
512         # matching the command line aleady exist
513         for filespec in filespecs:
514             entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
515             if os.path.exists(entry):
516                 raise GitException, 'Target "%s" already exists' % entry
517         
518         for filespec in filespecs:
519             filespec = filespec.rstrip('/')
520             basename = '/' + os.path.basename(filespec)
521             __copy_single(filespec, target, basename)
522
523     elif os.path.exists(target):
524         raise GitException, 'Target "%s" exists but is not a directory' % target
525     elif len(filespecs) != 1:
526         raise GitException, 'Cannot copy more than one file to non-directory'
527
528     else:
529         # at this point: len(filespecs)==1 and target does not exist
530
531         # check target directory
532         targetdir = os.path.dirname(target)
533         if targetdir != '' and not os.path.isdir(targetdir):
534             raise GitException, 'Target directory "%s" does not exist' % targetdir
535
536         __copy_single(filespecs[0].rstrip('/'), target)
537         
538
539 def rm(files, force = False):
540     """Remove a file from the repository
541     """
542     if not force:
543         for f in files:
544             if os.path.exists(f):
545                 raise GitException, '%s exists. Remove it first' %f
546         if files:
547             GRun('update-index', '--remove', '--').xargs(files)
548     else:
549         if files:
550             GRun('update-index', '--force-remove', '--').xargs(files)
551
552 # Persons caching
553 __user = None
554 __author = None
555 __committer = None
556
557 def user():
558     """Return the user information.
559     """
560     global __user
561     if not __user:
562         name=config.get('user.name')
563         email=config.get('user.email')
564         __user = Person(name, email)
565     return __user;
566
567 def author():
568     """Return the author information.
569     """
570     global __author
571     if not __author:
572         try:
573             # the environment variables take priority over config
574             try:
575                 date = os.environ['GIT_AUTHOR_DATE']
576             except KeyError:
577                 date = ''
578             __author = Person(os.environ['GIT_AUTHOR_NAME'],
579                               os.environ['GIT_AUTHOR_EMAIL'],
580                               date)
581         except KeyError:
582             __author = user()
583     return __author
584
585 def committer():
586     """Return the author information.
587     """
588     global __committer
589     if not __committer:
590         try:
591             # the environment variables take priority over config
592             try:
593                 date = os.environ['GIT_COMMITTER_DATE']
594             except KeyError:
595                 date = ''
596             __committer = Person(os.environ['GIT_COMMITTER_NAME'],
597                                  os.environ['GIT_COMMITTER_EMAIL'],
598                                  date)
599         except KeyError:
600             __committer = user()
601     return __committer
602
603 def update_cache(files = None, force = False):
604     """Update the cache information for the given files
605     """
606     cache_files = tree_status(files, verbose = False)
607
608     # everything is up-to-date
609     if len(cache_files) == 0:
610         return False
611
612     # check for unresolved conflicts
613     if not force and [x for x in cache_files
614                       if x[0] not in ['M', 'N', 'A', 'D']]:
615         raise GitException, 'Updating cache failed: unresolved conflicts'
616
617     # update the cache
618     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
619     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
620     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
621
622     GRun('update-index', '--add', '--').xargs(add_files)
623     GRun('update-index', '--force-remove', '--').xargs(rm_files)
624     GRun('update-index', '--').xargs(m_files)
625
626     return True
627
628 def commit(message, files = None, parents = None, allowempty = False,
629            cache_update = True, tree_id = None, set_head = False,
630            author_name = None, author_email = None, author_date = None,
631            committer_name = None, committer_email = None):
632     """Commit the current tree to repository
633     """
634     if not parents:
635         parents = []
636
637     # Get the tree status
638     if cache_update and parents != []:
639         changes = update_cache(files)
640         if not changes and not allowempty:
641             raise GitException, 'No changes to commit'
642
643     # get the commit message
644     if not message:
645         message = '\n'
646     elif message[-1:] != '\n':
647         message += '\n'
648
649     # write the index to repository
650     if tree_id == None:
651         tree_id = GRun('write-tree').output_one_line()
652         set_head = True
653
654     # the commit
655     env = {}
656     if author_name:
657         env['GIT_AUTHOR_NAME'] = author_name
658     if author_email:
659         env['GIT_AUTHOR_EMAIL'] = author_email
660     if author_date:
661         env['GIT_AUTHOR_DATE'] = author_date
662     if committer_name:
663         env['GIT_COMMITTER_NAME'] = committer_name
664     if committer_email:
665         env['GIT_COMMITTER_EMAIL'] = committer_email
666     commit_id = GRun('commit-tree', tree_id,
667                      *sum([['-p', p] for p in parents], [])
668                      ).env(env).raw_input(message).output_one_line()
669     if set_head:
670         __set_head(commit_id)
671
672     return commit_id
673
674 def apply_diff(rev1, rev2, check_index = True, files = None):
675     """Apply the diff between rev1 and rev2 onto the current
676     index. This function doesn't need to raise an exception since it
677     is only used for fast-pushing a patch. If this operation fails,
678     the pushing would fall back to the three-way merge.
679     """
680     if check_index:
681         index_opt = ['--index']
682     else:
683         index_opt = []
684
685     if not files:
686         files = []
687
688     diff_str = diff(files, rev1, rev2)
689     if diff_str:
690         try:
691             GRun('apply', *index_opt).raw_input(
692                 diff_str).discard_stderr().no_output()
693         except GitRunException:
694             return False
695
696     return True
697
698 stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
699
700 def merge_recursive(base, head1, head2):
701     """Perform a 3-way merge between base, head1 and head2 into the
702     local tree
703     """
704     refresh_index()
705
706     err_output = None
707     # this operation tracks renames but it is slower (used in
708     # general when pushing or picking patches)
709     try:
710         # discard output to mask the verbose prints of the tool
711         GRun('merge-recursive', base, '--', head1, head2).discard_output()
712     except GitRunException, ex:
713         err_output = str(ex)
714         pass
715
716     # check the index for unmerged entries
717     files = {}
718
719     for line in GRun('ls-files', '--unmerged', '--stage', '-z'
720                      ).raw_output().split('\0'):
721         if not line:
722             continue
723
724         mode, hash, stage, path = stages_re.findall(line)[0]
725
726         if not path in files:
727             files[path] = {}
728             files[path]['1'] = ('', '')
729             files[path]['2'] = ('', '')
730             files[path]['3'] = ('', '')
731
732         files[path][stage] = (mode, hash)
733
734     if err_output and not files:
735         # if no unmerged files, there was probably a different type of
736         # error and we have to abort the merge
737         raise GitException, err_output
738
739     if files:
740         raise GitException, 'GIT index merging failed (possible conflicts)'
741
742 def merge(base, head1, head2):
743     """Perform a 3-way merge between base, head1 and head2 into the
744     local tree
745     """
746     refresh_index()
747
748     err_output = None
749     # the fast case where we don't track renames (used when the
750     # distance between base and heads is small, i.e. folding or
751     # synchronising patches)
752     try:
753         GRun('read-tree', '-u', '-m', '--aggressive', base, head1, head2
754              ).run()
755     except GitRunException:
756         raise GitException, 'read-tree failed (local changes maybe?)'
757
758     # check the index for unmerged entries
759     files = {}
760     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
761
762     for line in GRun('ls-files', '--unmerged', '--stage', '-z'
763                      ).raw_output().split('\0'):
764         if not line:
765             continue
766
767         mode, hash, stage, path = stages_re.findall(line)[0]
768
769         if not path in files:
770             files[path] = {}
771             files[path]['1'] = ('', '')
772             files[path]['2'] = ('', '')
773             files[path]['3'] = ('', '')
774
775         files[path][stage] = (mode, hash)
776
777     if err_output and not files:
778         # if no unmerged files, there was probably a different type of
779         # error and we have to abort the merge
780         raise GitException, err_output
781
782     # merge the unmerged files
783     errors = False
784     for path in files:
785         # remove additional files that might be generated for some
786         # newer versions of GIT
787         for suffix in [base, head1, head2]:
788             if not suffix:
789                 continue
790             fname = path + '~' + suffix
791             if os.path.exists(fname):
792                 os.remove(fname)
793
794         stages = files[path]
795         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
796                                  stages['3'][1], path, stages['1'][0],
797                                  stages['2'][0], stages['3'][0]) != 0:
798             errors = True
799
800     if errors:
801         raise GitException, 'GIT index merging failed (possible conflicts)'
802
803 def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = [],
804          binary = True):
805     """Show the diff between rev1 and rev2
806     """
807     if not files:
808         files = []
809     if binary and '--binary' not in diff_flags:
810         diff_flags = diff_flags + ['--binary']
811
812     if rev1 and rev2:
813         return GRun('diff-tree', '-p',
814                     *(diff_flags + [rev1, rev2, '--'] + files)).raw_output()
815     elif rev1 or rev2:
816         refresh_index()
817         if rev2:
818             return GRun('diff-index', '-p', '-R',
819                         *(diff_flags + [rev2, '--'] + files)).raw_output()
820         else:
821             return GRun('diff-index', '-p',
822                         *(diff_flags + [rev1, '--'] + files)).raw_output()
823     else:
824         return ''
825
826 # TODO: take another parameter representing a diff string as we
827 # usually invoke git.diff() form the calling functions
828 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
829     """Return the diffstat between rev1 and rev2."""
830     return GRun('apply', '--stat', '--summary'
831                 ).raw_input(diff(files, rev1, rev2)).raw_output()
832
833 def files(rev1, rev2, diff_flags = []):
834     """Return the files modified between rev1 and rev2
835     """
836
837     result = []
838     for line in GRun('diff-tree', *(diff_flags + ['-r', rev1, rev2])
839                      ).output_lines():
840         result.append('%s %s' % tuple(line.split(' ', 4)[-1].split('\t', 1)))
841
842     return '\n'.join(result)
843
844 def barefiles(rev1, rev2):
845     """Return the files modified between rev1 and rev2, without status info
846     """
847
848     result = []
849     for line in GRun('diff-tree', '-r', rev1, rev2).output_lines():
850         result.append(line.split(' ', 4)[-1].split('\t', 1)[-1])
851
852     return '\n'.join(result)
853
854 def pretty_commit(commit_id = 'HEAD', flags = []):
855     """Return a given commit (log + diff)
856     """
857     return GRun('show', *(flags + [commit_id])).raw_output()
858
859 def checkout(files = None, tree_id = None, force = False):
860     """Check out the given or all files
861     """
862     if tree_id:
863         try:
864             GRun('read-tree', '--reset', tree_id).run()
865         except GitRunException:
866             raise GitException, 'Failed "git read-tree" --reset %s' % tree_id
867
868     cmd = ['checkout-index', '-q', '-u']
869     if force:
870         cmd.append('-f')
871     if files:
872         GRun(*(cmd + ['--'])).xargs(files)
873     else:
874         GRun(*(cmd + ['-a'])).run()
875
876 def switch(tree_id, keep = False):
877     """Switch the tree to the given id
878     """
879     if keep:
880         # only update the index while keeping the local changes
881         GRun('read-tree', tree_id).run()
882     else:
883         refresh_index()
884         try:
885             GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
886         except GitRunException:
887             raise GitException, 'read-tree failed (local changes maybe?)'
888
889     __set_head(tree_id)
890
891 def reset(files = None, tree_id = None, check_out = True):
892     """Revert the tree changes relative to the given tree_id. It removes
893     any local changes
894     """
895     if not tree_id:
896         tree_id = get_head()
897
898     if check_out:
899         cache_files = tree_status(files, tree_id)
900         # files which were added but need to be removed
901         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
902
903         checkout(files, tree_id, True)
904         # checkout doesn't remove files
905         map(os.remove, rm_files)
906
907     # if the reset refers to the whole tree, switch the HEAD as well
908     if not files:
909         __set_head(tree_id)
910
911 def fetch(repository = 'origin', refspec = None):
912     """Fetches changes from the remote repository, using 'git fetch'
913     by default.
914     """
915     # we update the HEAD
916     __clear_head_cache()
917
918     args = [repository]
919     if refspec:
920         args.append(refspec)
921
922     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
923               config.get('stgit.fetchcmd')
924     Run(*(command.split() + args)).run()
925
926 def pull(repository = 'origin', refspec = None):
927     """Fetches changes from the remote repository, using 'git pull'
928     by default.
929     """
930     # we update the HEAD
931     __clear_head_cache()
932
933     args = [repository]
934     if refspec:
935         args.append(refspec)
936
937     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
938               config.get('stgit.pullcmd')
939     Run(*(command.split() + args)).run()
940
941 def rebase(tree_id = None):
942     """Rebase the current tree to the give tree_id. The tree_id
943     argument may be something other than a GIT id if an external
944     command is invoked.
945     """
946     command = config.get('branch.%s.stgit.rebasecmd' % get_head_file()) \
947                 or config.get('stgit.rebasecmd')
948     if tree_id:
949         args = [tree_id]
950     elif command:
951         args = []
952     else:
953         raise GitException, 'Default rebasing requires a commit id'
954     if command:
955         # clear the HEAD cache as the custom rebase command will update it
956         __clear_head_cache()
957         Run(*(command.split() + args)).run()
958     else:
959         # default rebasing
960         reset(tree_id = tree_id)
961
962 def repack():
963     """Repack all objects into a single pack
964     """
965     GRun('repack', '-a', '-d', '-f').run()
966
967 def apply_patch(filename = None, diff = None, base = None,
968                 fail_dump = True):
969     """Apply a patch onto the current or given index. There must not
970     be any local changes in the tree, otherwise the command fails
971     """
972     if diff is None:
973         if filename:
974             f = file(filename)
975         else:
976             f = sys.stdin
977         diff = f.read()
978         if filename:
979             f.close()
980
981     if base:
982         orig_head = get_head()
983         switch(base)
984     else:
985         refresh_index()
986
987     try:
988         GRun('apply', '--index').raw_input(diff).no_output()
989     except GitRunException:
990         if base:
991             switch(orig_head)
992         if fail_dump:
993             # write the failed diff to a file
994             f = file('.stgit-failed.patch', 'w+')
995             f.write(diff)
996             f.close()
997             out.warn('Diff written to the .stgit-failed.patch file')
998
999         raise
1000
1001     if base:
1002         top = commit(message = 'temporary commit used for applying a patch',
1003                      parents = [base])
1004         switch(orig_head)
1005         merge(base, orig_head, top)
1006
1007 def clone(repository, local_dir):
1008     """Clone a remote repository. At the moment, just use the
1009     'git clone' script
1010     """
1011     GRun('clone', repository, local_dir).run()
1012
1013 def modifying_revs(files, base_rev, head_rev):
1014     """Return the revisions from the list modifying the given files."""
1015     return GRun('rev-list', '%s..%s' % (base_rev, head_rev), '--', *files
1016                 ).output_lines()
1017
1018 def refspec_localpart(refspec):
1019     m = re.match('^[^:]*:([^:]*)$', refspec)
1020     if m:
1021         return m.group(1)
1022     else:
1023         raise GitException, 'Cannot parse refspec "%s"' % line
1024
1025 def refspec_remotepart(refspec):
1026     m = re.match('^([^:]*):[^:]*$', refspec)
1027     if m:
1028         return m.group(1)
1029     else:
1030         raise GitException, 'Cannot parse refspec "%s"' % line
1031     
1032
1033 def __remotes_from_config():
1034     return config.sections_matching(r'remote\.(.*)\.url')
1035
1036 def __remotes_from_dir(dir):
1037     d = os.path.join(basedir.get(), dir)
1038     if os.path.exists(d):
1039         return os.listdir(d)
1040     else:
1041         return []
1042
1043 def remotes_list():
1044     """Return the list of remotes in the repository
1045     """
1046     return (set(__remotes_from_config())
1047             | set(__remotes_from_dir('remotes'))
1048             | set(__remotes_from_dir('branches')))
1049
1050 def remotes_local_branches(remote):
1051     """Returns the list of local branches fetched from given remote
1052     """
1053
1054     branches = []
1055     if remote in __remotes_from_config():
1056         for line in config.getall('remote.%s.fetch' % remote):
1057             branches.append(refspec_localpart(line))
1058     elif remote in __remotes_from_dir('remotes'):
1059         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1060         for line in stream:
1061             # Only consider Pull lines
1062             m = re.match('^Pull: (.*)\n$', line)
1063             if m:
1064                 branches.append(refspec_localpart(m.group(1)))
1065         stream.close()
1066     elif remote in __remotes_from_dir('branches'):
1067         # old-style branches only declare one branch
1068         branches.append('refs/heads/'+remote);
1069     else:
1070         raise GitException, 'Unknown remote "%s"' % remote
1071
1072     return branches
1073
1074 def identify_remote(branchname):
1075     """Return the name for the remote to pull the given branchname
1076     from, or None if we believe it is a local branch.
1077     """
1078
1079     for remote in remotes_list():
1080         if branchname in remotes_local_branches(remote):
1081             return remote
1082
1083     # if we get here we've found nothing, the branch is a local one
1084     return None
1085
1086 def fetch_head():
1087     """Return the git id for the tip of the parent branch as left by
1088     'git fetch'.
1089     """
1090
1091     fetch_head=None
1092     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1093     for line in stream:
1094         # Only consider lines not tagged not-for-merge
1095         m = re.match('^([^\t]*)\t\t', line)
1096         if m:
1097             if fetch_head:
1098                 raise GitException, 'StGit does not support multiple FETCH_HEAD'
1099             else:
1100                 fetch_head=m.group(1)
1101     stream.close()
1102
1103     if not fetch_head:
1104         out.warn('No for-merge remote head found in FETCH_HEAD')
1105
1106     # here we are sure to have a single fetch_head
1107     return fetch_head
1108
1109 def all_refs():
1110     """Return a list of all refs in the current repository.
1111     """
1112
1113     return [line.split()[1] for line in GRun('show-ref').output_lines()]