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