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