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