chiark / gitweb /
659bd7b152154136fbfaae1a28db314723f557c9
[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).returns([0, 1])
714     output = p.output_lines()
715     if p.exitcode == 0:
716         # No problems
717         return
718     else: # exitcode == 1
719         # There were conflicts
720         conflicts = [l.strip() for l in output if l.startswith('CONFLICT')]
721         raise GitConflictException(conflicts)
722
723 def merge(base, head1, head2):
724     """Perform a 3-way merge between base, head1 and head2 into the
725     local tree
726     """
727     refresh_index()
728
729     err_output = None
730     # the fast case where we don't track renames (used when the
731     # distance between base and heads is small, i.e. folding or
732     # synchronising patches)
733     try:
734         GRun('read-tree', '-u', '-m', '--aggressive', base, head1, head2
735              ).run()
736     except GitRunException:
737         raise GitException, 'read-tree failed (local changes maybe?)'
738
739     # check the index for unmerged entries
740     files = {}
741     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
742
743     for line in GRun('ls-files', '--unmerged', '--stage', '-z'
744                      ).raw_output().split('\0'):
745         if not line:
746             continue
747
748         mode, hash, stage, path = stages_re.findall(line)[0]
749
750         if not path in files:
751             files[path] = {}
752             files[path]['1'] = ('', '')
753             files[path]['2'] = ('', '')
754             files[path]['3'] = ('', '')
755
756         files[path][stage] = (mode, hash)
757
758     if err_output and not files:
759         # if no unmerged files, there was probably a different type of
760         # error and we have to abort the merge
761         raise GitException, err_output
762
763     # merge the unmerged files
764     errors = False
765     for path in files:
766         # remove additional files that might be generated for some
767         # newer versions of GIT
768         for suffix in [base, head1, head2]:
769             if not suffix:
770                 continue
771             fname = path + '~' + suffix
772             if os.path.exists(fname):
773                 os.remove(fname)
774
775         stages = files[path]
776         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
777                                  stages['3'][1], path, stages['1'][0],
778                                  stages['2'][0], stages['3'][0]) != 0:
779             errors = True
780
781     if errors:
782         raise GitException, 'GIT index merging failed (possible conflicts)'
783
784 def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = [],
785          binary = True):
786     """Show the diff between rev1 and rev2
787     """
788     if not files:
789         files = []
790     if binary and '--binary' not in diff_flags:
791         diff_flags = diff_flags + ['--binary']
792
793     if rev1 and rev2:
794         return GRun('diff-tree', '-p',
795                     *(diff_flags + [rev1, rev2, '--'] + files)).raw_output()
796     elif rev1 or rev2:
797         refresh_index()
798         if rev2:
799             return GRun('diff-index', '-p', '-R',
800                         *(diff_flags + [rev2, '--'] + files)).raw_output()
801         else:
802             return GRun('diff-index', '-p',
803                         *(diff_flags + [rev1, '--'] + files)).raw_output()
804     else:
805         return ''
806
807 # TODO: take another parameter representing a diff string as we
808 # usually invoke git.diff() form the calling functions
809 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
810     """Return the diffstat between rev1 and rev2."""
811     return GRun('apply', '--stat', '--summary'
812                 ).raw_input(diff(files, rev1, rev2)).raw_output()
813
814 def files(rev1, rev2, diff_flags = []):
815     """Return the files modified between rev1 and rev2
816     """
817
818     result = []
819     for line in GRun('diff-tree', *(diff_flags + ['-r', rev1, rev2])
820                      ).output_lines():
821         result.append('%s %s' % tuple(line.split(' ', 4)[-1].split('\t', 1)))
822
823     return '\n'.join(result)
824
825 def barefiles(rev1, rev2):
826     """Return the files modified between rev1 and rev2, without status info
827     """
828
829     result = []
830     for line in GRun('diff-tree', '-r', rev1, rev2).output_lines():
831         result.append(line.split(' ', 4)[-1].split('\t', 1)[-1])
832
833     return '\n'.join(result)
834
835 def pretty_commit(commit_id = 'HEAD', flags = []):
836     """Return a given commit (log + diff)
837     """
838     return GRun('show', *(flags + [commit_id])).raw_output()
839
840 def checkout(files = None, tree_id = None, force = False):
841     """Check out the given or all files
842     """
843     if tree_id:
844         try:
845             GRun('read-tree', '--reset', tree_id).run()
846         except GitRunException:
847             raise GitException, 'Failed "git read-tree" --reset %s' % tree_id
848
849     cmd = ['checkout-index', '-q', '-u']
850     if force:
851         cmd.append('-f')
852     if files:
853         GRun(*(cmd + ['--'])).xargs(files)
854     else:
855         GRun(*(cmd + ['-a'])).run()
856
857 def switch(tree_id, keep = False):
858     """Switch the tree to the given id
859     """
860     if keep:
861         # only update the index while keeping the local changes
862         GRun('read-tree', tree_id).run()
863     else:
864         refresh_index()
865         try:
866             GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
867         except GitRunException:
868             raise GitException, 'read-tree failed (local changes maybe?)'
869
870     __set_head(tree_id)
871
872 def reset(files = None, tree_id = None, check_out = True):
873     """Revert the tree changes relative to the given tree_id. It removes
874     any local changes
875     """
876     if not tree_id:
877         tree_id = get_head()
878
879     if check_out:
880         cache_files = tree_status(files, tree_id)
881         # files which were added but need to be removed
882         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
883
884         checkout(files, tree_id, True)
885         # checkout doesn't remove files
886         map(os.remove, rm_files)
887
888     # if the reset refers to the whole tree, switch the HEAD as well
889     if not files:
890         __set_head(tree_id)
891
892 def fetch(repository = 'origin', refspec = None):
893     """Fetches changes from the remote repository, using 'git fetch'
894     by default.
895     """
896     # we update the HEAD
897     __clear_head_cache()
898
899     args = [repository]
900     if refspec:
901         args.append(refspec)
902
903     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
904               config.get('stgit.fetchcmd')
905     Run(*(command.split() + args)).run()
906
907 def pull(repository = 'origin', refspec = None):
908     """Fetches changes from the remote repository, using 'git pull'
909     by default.
910     """
911     # we update the HEAD
912     __clear_head_cache()
913
914     args = [repository]
915     if refspec:
916         args.append(refspec)
917
918     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
919               config.get('stgit.pullcmd')
920     Run(*(command.split() + args)).run()
921
922 def rebase(tree_id = None):
923     """Rebase the current tree to the give tree_id. The tree_id
924     argument may be something other than a GIT id if an external
925     command is invoked.
926     """
927     command = config.get('branch.%s.stgit.rebasecmd' % get_head_file()) \
928                 or config.get('stgit.rebasecmd')
929     if tree_id:
930         args = [tree_id]
931     elif command:
932         args = []
933     else:
934         raise GitException, 'Default rebasing requires a commit id'
935     if command:
936         # clear the HEAD cache as the custom rebase command will update it
937         __clear_head_cache()
938         Run(*(command.split() + args)).run()
939     else:
940         # default rebasing
941         reset(tree_id = tree_id)
942
943 def repack():
944     """Repack all objects into a single pack
945     """
946     GRun('repack', '-a', '-d', '-f').run()
947
948 def apply_patch(filename = None, diff = None, base = None,
949                 fail_dump = True):
950     """Apply a patch onto the current or given index. There must not
951     be any local changes in the tree, otherwise the command fails
952     """
953     if diff is None:
954         if filename:
955             f = file(filename)
956         else:
957             f = sys.stdin
958         diff = f.read()
959         if filename:
960             f.close()
961
962     if base:
963         orig_head = get_head()
964         switch(base)
965     else:
966         refresh_index()
967
968     try:
969         GRun('apply', '--index').raw_input(diff).no_output()
970     except GitRunException:
971         if base:
972             switch(orig_head)
973         if fail_dump:
974             # write the failed diff to a file
975             f = file('.stgit-failed.patch', 'w+')
976             f.write(diff)
977             f.close()
978             out.warn('Diff written to the .stgit-failed.patch file')
979
980         raise
981
982     if base:
983         top = commit(message = 'temporary commit used for applying a patch',
984                      parents = [base])
985         switch(orig_head)
986         merge(base, orig_head, top)
987
988 def clone(repository, local_dir):
989     """Clone a remote repository. At the moment, just use the
990     'git clone' script
991     """
992     GRun('clone', repository, local_dir).run()
993
994 def modifying_revs(files, base_rev, head_rev):
995     """Return the revisions from the list modifying the given files."""
996     return GRun('rev-list', '%s..%s' % (base_rev, head_rev), '--', *files
997                 ).output_lines()
998
999 def refspec_localpart(refspec):
1000     m = re.match('^[^:]*:([^:]*)$', refspec)
1001     if m:
1002         return m.group(1)
1003     else:
1004         raise GitException, 'Cannot parse refspec "%s"' % line
1005
1006 def refspec_remotepart(refspec):
1007     m = re.match('^([^:]*):[^:]*$', refspec)
1008     if m:
1009         return m.group(1)
1010     else:
1011         raise GitException, 'Cannot parse refspec "%s"' % line
1012     
1013
1014 def __remotes_from_config():
1015     return config.sections_matching(r'remote\.(.*)\.url')
1016
1017 def __remotes_from_dir(dir):
1018     d = os.path.join(basedir.get(), dir)
1019     if os.path.exists(d):
1020         return os.listdir(d)
1021     else:
1022         return []
1023
1024 def remotes_list():
1025     """Return the list of remotes in the repository
1026     """
1027     return (set(__remotes_from_config())
1028             | set(__remotes_from_dir('remotes'))
1029             | set(__remotes_from_dir('branches')))
1030
1031 def remotes_local_branches(remote):
1032     """Returns the list of local branches fetched from given remote
1033     """
1034
1035     branches = []
1036     if remote in __remotes_from_config():
1037         for line in config.getall('remote.%s.fetch' % remote):
1038             branches.append(refspec_localpart(line))
1039     elif remote in __remotes_from_dir('remotes'):
1040         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1041         for line in stream:
1042             # Only consider Pull lines
1043             m = re.match('^Pull: (.*)\n$', line)
1044             if m:
1045                 branches.append(refspec_localpart(m.group(1)))
1046         stream.close()
1047     elif remote in __remotes_from_dir('branches'):
1048         # old-style branches only declare one branch
1049         branches.append('refs/heads/'+remote);
1050     else:
1051         raise GitException, 'Unknown remote "%s"' % remote
1052
1053     return branches
1054
1055 def identify_remote(branchname):
1056     """Return the name for the remote to pull the given branchname
1057     from, or None if we believe it is a local branch.
1058     """
1059
1060     for remote in remotes_list():
1061         if branchname in remotes_local_branches(remote):
1062             return remote
1063
1064     # if we get here we've found nothing, the branch is a local one
1065     return None
1066
1067 def fetch_head():
1068     """Return the git id for the tip of the parent branch as left by
1069     'git fetch'.
1070     """
1071
1072     fetch_head=None
1073     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1074     for line in stream:
1075         # Only consider lines not tagged not-for-merge
1076         m = re.match('^([^\t]*)\t\t', line)
1077         if m:
1078             if fetch_head:
1079                 raise GitException, 'StGit does not support multiple FETCH_HEAD'
1080             else:
1081                 fetch_head=m.group(1)
1082     stream.close()
1083
1084     if not fetch_head:
1085         out.warn('No for-merge remote head found in FETCH_HEAD')
1086
1087     # here we are sure to have a single fetch_head
1088     return fetch_head
1089
1090 def all_refs():
1091     """Return a list of all refs in the current repository.
1092     """
1093
1094     return [line.split()[1] for line in GRun('show-ref').output_lines()]