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