chiark / gitweb /
86630ce3900af65d130116aaa1532638de5d4cd7
[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 %s' % 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 %s'
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)' % (str(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)' % (str(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)' % (str(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)' % (str(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)' % (str(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 __tree_status(files = None, tree_id = 'HEAD', unknown = False,
220                   noexclude = True, verbose = False):
221     """Returns a list of pairs - [status, filename]
222     """
223     if verbose and sys.stdout.isatty():
224         print 'Checking for changes in the working directory...',
225         sys.stdout.flush()
226
227     refresh_index()
228
229     if not files:
230         files = []
231     cache_files = []
232
233     # unknown files
234     if unknown:
235         exclude_file = os.path.join(basedir.get(), 'info', 'exclude')
236         base_exclude = ['--exclude=%s' % s for s in
237                         ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
238         base_exclude.append('--exclude-per-directory=.gitignore')
239
240         if os.path.exists(exclude_file):
241             extra_exclude = ['--exclude-from=%s' % exclude_file]
242         else:
243             extra_exclude = []
244         if noexclude:
245             extra_exclude = base_exclude = []
246
247         lines = _output_lines(['git-ls-files', '--others', '--directory']
248                         + base_exclude + extra_exclude)
249         cache_files += [('?', line.strip()) for line in lines]
250
251     # conflicted files
252     conflicts = get_conflicts()
253     if not conflicts:
254         conflicts = []
255     cache_files += [('C', filename) for filename in conflicts]
256
257     # the rest
258     for line in _output_lines(['git-diff-index', tree_id, '--'] + files):
259         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
260         if fs[1] not in conflicts:
261             cache_files.append(fs)
262
263     if verbose and sys.stdout.isatty():
264         print '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 # HEAD value cached
274 __head = None
275
276 def get_head():
277     """Verifies the HEAD and returns the SHA1 id that represents it
278     """
279     global __head
280
281     if not __head:
282         __head = rev_parse('HEAD')
283     return __head
284
285 def get_head_file():
286     """Returns the name of the file pointed to by the HEAD link
287     """
288     return strip_prefix('refs/heads/',
289                         _output_one_line('git-symbolic-ref HEAD'))
290
291 def set_head_file(ref):
292     """Resets HEAD to point to a new ref
293     """
294     # head cache flushing is needed since we might have a different value
295     # in the new head
296     __clear_head_cache()
297     if __run('git-symbolic-ref HEAD',
298              [os.path.join('refs', 'heads', ref)]) != 0:
299         raise GitException, 'Could not set head to "%s"' % ref
300
301 def set_branch(branch, val):
302     """Point branch at a new commit object."""
303     if __run('git-update-ref', [branch, val]) != 0:
304         raise GitException, 'Could not update %s to "%s".' % (branch, val)
305
306 def __set_head(val):
307     """Sets the HEAD value
308     """
309     global __head
310
311     if not __head or __head != val:
312         set_branch('HEAD', val)
313         __head = val
314
315     # only allow SHA1 hashes
316     assert(len(__head) == 40)
317
318 def __clear_head_cache():
319     """Sets the __head to None so that a re-read is forced
320     """
321     global __head
322
323     __head = None
324
325 def refresh_index():
326     """Refresh index with stat() information from the working directory.
327     """
328     __run('git-update-index -q --unmerged --refresh')
329
330 def rev_parse(git_id):
331     """Parse the string and return a verified SHA1 id
332     """
333     try:
334         return _output_one_line(['git-rev-parse', '--verify', git_id])
335     except GitException:
336         raise GitException, 'Unknown revision: %s' % git_id
337
338 def branch_exists(branch):
339     """Existence check for the named branch
340     """
341     branch = os.path.join('refs', 'heads', branch)
342     for line in _output_lines('git-rev-parse --symbolic --all 2>&1'):
343         if line.strip() == branch:
344             return True
345         if re.compile('[ |/]'+branch+' ').search(line):
346             raise GitException, 'Bogus branch: %s' % line
347     return False
348
349 def create_branch(new_branch, tree_id = None):
350     """Create a new branch in the git repository
351     """
352     if branch_exists(new_branch):
353         raise GitException, 'Branch "%s" already exists' % new_branch
354
355     current_head = get_head()
356     set_head_file(new_branch)
357     __set_head(current_head)
358
359     # a checkout isn't needed if new branch points to the current head
360     if tree_id:
361         switch(tree_id)
362
363     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
364         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
365
366 def switch_branch(new_branch):
367     """Switch to a git branch
368     """
369     global __head
370
371     if not branch_exists(new_branch):
372         raise GitException, 'Branch "%s" does not exist' % new_branch
373
374     tree_id = rev_parse(os.path.join('refs', 'heads', new_branch)
375                         + '^{commit}')
376     if tree_id != get_head():
377         refresh_index()
378         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
379             raise GitException, 'git-read-tree failed (local changes maybe?)'
380         __head = tree_id
381     set_head_file(new_branch)
382
383     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
384         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
385
386 def delete_branch(name):
387     """Delete a git branch
388     """
389     if not branch_exists(name):
390         raise GitException, 'Branch "%s" does not exist' % name
391     remove_file_and_dirs(os.path.join(basedir.get(), 'refs', 'heads'),
392                          name)
393
394 def rename_branch(from_name, to_name):
395     """Rename a git branch
396     """
397     if not branch_exists(from_name):
398         raise GitException, 'Branch "%s" does not exist' % from_name
399     if branch_exists(to_name):
400         raise GitException, 'Branch "%s" already exists' % to_name
401
402     if get_head_file() == from_name:
403         set_head_file(to_name)
404     rename(os.path.join(basedir.get(), 'refs', 'heads'),
405            from_name, to_name)
406
407     reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
408     if os.path.exists(reflog_dir) \
409            and os.path.exists(os.path.join(reflog_dir, from_name)):
410         rename(reflog_dir, from_name, to_name)
411
412 def add(names):
413     """Add the files or recursively add the directory contents
414     """
415     # generate the file list
416     files = []
417     for i in names:
418         if not os.path.exists(i):
419             raise GitException, 'Unknown file or directory: %s' % i
420
421         if os.path.isdir(i):
422             # recursive search. We only add files
423             for root, dirs, local_files in os.walk(i):
424                 for name in [os.path.join(root, f) for f in local_files]:
425                     if os.path.isfile(name):
426                         files.append(os.path.normpath(name))
427         elif os.path.isfile(i):
428             files.append(os.path.normpath(i))
429         else:
430             raise GitException, '%s is not a file or directory' % i
431
432     if files:
433         if __run('git-update-index --add --', files):
434             raise GitException, 'Unable to add file'
435
436 def __copy_single(source, target, target2=''):
437     """Copy file or dir named 'source' to name target+target2"""
438
439     # "source" (file or dir) must match one or more git-controlled file
440     realfiles = _output_lines(['git-ls-files', source])
441     if len(realfiles) == 0:
442         raise GitException, '"%s" matches no git-controled files' % source
443
444     if os.path.isdir(source):
445         # physically copy the files, and record them to add them in one run
446         newfiles = []
447         re_string='^'+source+'/(.*)$'
448         prefix_regexp = re.compile(re_string)
449         for f in [f.strip() for f in realfiles]:
450             m = prefix_regexp.match(f)
451             if not m:
452                 print '"%s" does not match "%s"' % (f, re_string)
453                 assert(m)
454             newname = target+target2+'/'+m.group(1)
455             if not os.path.exists(os.path.dirname(newname)):
456                 os.makedirs(os.path.dirname(newname))
457             copyfile(f, newname)
458             newfiles.append(newname)
459
460         add(newfiles)
461     else: # files, symlinks, ...
462         newname = target+target2
463         copyfile(source, newname)
464         add([newname])
465
466
467 def copy(filespecs, target):
468     if os.path.isdir(target):
469         # target is a directory: copy each entry on the command line,
470         # with the same name, into the target
471         target = target.rstrip('/')
472         
473         # first, check that none of the children of the target
474         # matching the command line aleady exist
475         for filespec in filespecs:
476             entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
477             if os.path.exists(entry):
478                 raise GitException, 'Target "%s" already exists' % entry
479         
480         for filespec in filespecs:
481             filespec = filespec.rstrip('/')
482             basename = '/' + os.path.basename(filespec)
483             __copy_single(filespec, target, basename)
484
485     elif os.path.exists(target):
486         raise GitException, 'Target "%s" exists but is not a directory' % target
487     elif len(filespecs) != 1:
488         raise GitException, 'Cannot copy more than one file to non-directory'
489
490     else:
491         # at this point: len(filespecs)==1 and target does not exist
492
493         # check target directory
494         targetdir = os.path.dirname(target)
495         if targetdir != '' and not os.path.isdir(targetdir):
496             raise GitException, 'Target directory "%s" does not exist' % targetdir
497
498         __copy_single(filespecs[0].rstrip('/'), target)
499         
500
501 def rm(files, force = False):
502     """Remove a file from the repository
503     """
504     if not force:
505         for f in files:
506             if os.path.exists(f):
507                 raise GitException, '%s exists. Remove it first' %f
508         if files:
509             __run('git-update-index --remove --', files)
510     else:
511         if files:
512             __run('git-update-index --force-remove --', files)
513
514 # Persons caching
515 __user = None
516 __author = None
517 __committer = None
518
519 def user():
520     """Return the user information.
521     """
522     global __user
523     if not __user:
524         name=config.get('user.name')
525         email=config.get('user.email')
526         __user = Person(name, email)
527     return __user;
528
529 def author():
530     """Return the author information.
531     """
532     global __author
533     if not __author:
534         try:
535             # the environment variables take priority over config
536             try:
537                 date = os.environ['GIT_AUTHOR_DATE']
538             except KeyError:
539                 date = ''
540             __author = Person(os.environ['GIT_AUTHOR_NAME'],
541                               os.environ['GIT_AUTHOR_EMAIL'],
542                               date)
543         except KeyError:
544             __author = user()
545     return __author
546
547 def committer():
548     """Return the author information.
549     """
550     global __committer
551     if not __committer:
552         try:
553             # the environment variables take priority over config
554             try:
555                 date = os.environ['GIT_COMMITTER_DATE']
556             except KeyError:
557                 date = ''
558             __committer = Person(os.environ['GIT_COMMITTER_NAME'],
559                                  os.environ['GIT_COMMITTER_EMAIL'],
560                                  date)
561         except KeyError:
562             __committer = user()
563     return __committer
564
565 def update_cache(files = None, force = False):
566     """Update the cache information for the given files
567     """
568     if not files:
569         files = []
570
571     cache_files = __tree_status(files, verbose = False)
572
573     # everything is up-to-date
574     if len(cache_files) == 0:
575         return False
576
577     # check for unresolved conflicts
578     if not force and [x for x in cache_files
579                       if x[0] not in ['M', 'N', 'A', 'D']]:
580         raise GitException, 'Updating cache failed: unresolved conflicts'
581
582     # update the cache
583     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
584     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
585     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
586
587     if add_files and __run('git-update-index --add --', add_files) != 0:
588         raise GitException, 'Failed git-update-index --add'
589     if rm_files and __run('git-update-index --force-remove --', rm_files) != 0:
590         raise GitException, 'Failed git-update-index --rm'
591     if m_files and __run('git-update-index --', m_files) != 0:
592         raise GitException, 'Failed git-update-index'
593
594     return True
595
596 def commit(message, files = None, parents = None, allowempty = False,
597            cache_update = True, tree_id = None,
598            author_name = None, author_email = None, author_date = None,
599            committer_name = None, committer_email = None):
600     """Commit the current tree to repository
601     """
602     if not files:
603         files = []
604     if not parents:
605         parents = []
606
607     # Get the tree status
608     if cache_update and parents != []:
609         changes = update_cache(files)
610         if not changes and not allowempty:
611             raise GitException, 'No changes to commit'
612
613     # get the commit message
614     if not message:
615         message = '\n'
616     elif message[-1:] != '\n':
617         message += '\n'
618
619     must_switch = True
620     # write the index to repository
621     if tree_id == None:
622         tree_id = _output_one_line('git-write-tree')
623     else:
624         must_switch = False
625
626     # the commit
627     cmd = ''
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 %s' % tree_id
639
640     # get the parents
641     for p in parents:
642         cmd += ' -p %s' % p
643
644     commit_id = _output_one_line(cmd, message)
645     if must_switch:
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 %s' % 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 %s -- %s %s' % (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     """Show the tree status
744     """
745     if not files:
746         files = []
747
748     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
749     all = not (modified or new or deleted or conflict or unknown)
750
751     if not all:
752         filestat = []
753         if modified:
754             filestat.append('M')
755         if new:
756             filestat.append('A')
757             filestat.append('N')
758         if deleted:
759             filestat.append('D')
760         if conflict:
761             filestat.append('C')
762         if unknown:
763             filestat.append('?')
764         cache_files = [x for x in cache_files if x[0] in filestat]
765
766     for fs in cache_files:
767         if files and not fs[1] in files:
768             continue
769         if all:
770             print '%s %s' % (fs[0], fs[1])
771         else:
772             print '%s' % fs[1]
773
774 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None,
775          binary = False):
776     """Show the diff between rev1 and rev2
777     """
778     if not files:
779         files = []
780
781     args = []
782     if binary:
783         args.append('--binary')
784
785     if rev1 and rev2:
786         diff_str = _output(['git-diff-tree', '-p'] + args
787                            + [rev1, rev2, '--'] + files)
788     elif rev1 or rev2:
789         refresh_index()
790         if rev2:
791             diff_str = _output(['git-diff-index', '-p', '-R']
792                                + args + [rev2, '--'] + files)
793         else:
794             diff_str = _output(['git-diff-index', '-p']
795                                + args + [rev1, '--'] + files)
796     else:
797         diff_str = ''
798
799     if out_fd:
800         out_fd.write(diff_str)
801     else:
802         return diff_str
803
804 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
805     """Return the diffstat between rev1 and rev2
806     """
807     if not files:
808         files = []
809
810     p=popen2.Popen3('git-apply --stat')
811     diff(files, rev1, rev2, p.tochild)
812     p.tochild.close()
813     diff_str = p.fromchild.read().rstrip()
814     if p.wait():
815         raise GitException, 'git.diffstat failed'
816     return diff_str
817
818 def files(rev1, rev2):
819     """Return the files modified between rev1 and rev2
820     """
821
822     result = ''
823     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
824         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
825
826     return result.rstrip()
827
828 def barefiles(rev1, rev2):
829     """Return the files modified between rev1 and rev2, without status info
830     """
831
832     result = ''
833     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
834         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
835
836     return result.rstrip()
837
838 def pretty_commit(commit_id = 'HEAD'):
839     """Return a given commit (log + diff)
840     """
841     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
842                     commit_id])
843
844 def checkout(files = None, tree_id = None, force = False):
845     """Check out the given or all files
846     """
847     if not files:
848         files = []
849
850     if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
851         raise GitException, 'Failed git-read-tree --reset %s' % tree_id
852
853     checkout_cmd = 'git-checkout-index -q -u'
854     if force:
855         checkout_cmd += ' -f'
856     if len(files) == 0:
857         checkout_cmd += ' -a'
858     else:
859         checkout_cmd += ' --'
860
861     if __run(checkout_cmd, files) != 0:
862         raise GitException, 'Failed git-checkout-index'
863
864 def switch(tree_id, keep = False):
865     """Switch the tree to the given id
866     """
867     if not keep:
868         refresh_index()
869         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
870             raise GitException, 'git-read-tree failed (local changes maybe?)'
871
872     __set_head(tree_id)
873
874 def reset(files = None, tree_id = None, check_out = True):
875     """Revert the tree changes relative to the given tree_id. It removes
876     any local changes
877     """
878     if not tree_id:
879         tree_id = get_head()
880
881     if check_out:
882         cache_files = __tree_status(files, tree_id)
883         # files which were added but need to be removed
884         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
885
886         checkout(files, tree_id, True)
887         # checkout doesn't remove files
888         map(os.remove, rm_files)
889
890     # if the reset refers to the whole tree, switch the HEAD as well
891     if not files:
892         __set_head(tree_id)
893
894 def fetch(repository = 'origin', refspec = None):
895     """Fetches changes from the remote repository, using 'git-fetch'
896     by default.
897     """
898     # we update the HEAD
899     __clear_head_cache()
900
901     args = [repository]
902     if refspec:
903         args.append(refspec)
904
905     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
906               config.get('stgit.fetchcmd')
907     if __run(command, args) != 0:
908         raise GitException, 'Failed "%s %s"' % (command, repository)
909
910 def pull(repository = 'origin', refspec = None):
911     """Fetches changes from the remote repository, using 'git-pull'
912     by default.
913     """
914     # we update the HEAD
915     __clear_head_cache()
916
917     args = [repository]
918     if refspec:
919         args.append(refspec)
920
921     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
922               config.get('stgit.pullcmd')
923     if __run(command, args) != 0:
924         raise GitException, 'Failed "%s %s"' % (command, repository)
925
926 def repack():
927     """Repack all objects into a single pack
928     """
929     __run('git-repack -a -d -f')
930
931 def apply_patch(filename = None, diff = None, base = None,
932                 fail_dump = True):
933     """Apply a patch onto the current or given index. There must not
934     be any local changes in the tree, otherwise the command fails
935     """
936     if diff is None:
937         if filename:
938             f = file(filename)
939         else:
940             f = sys.stdin
941         diff = f.read()
942         if filename:
943             f.close()
944
945     if base:
946         orig_head = get_head()
947         switch(base)
948     else:
949         refresh_index()
950
951     try:
952         _input_str('git-apply --index', diff)
953     except GitException:
954         if base:
955             switch(orig_head)
956         if fail_dump:
957             # write the failed diff to a file
958             f = file('.stgit-failed.patch', 'w+')
959             f.write(diff)
960             f.close()
961             print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
962
963         raise
964
965     if base:
966         top = commit(message = 'temporary commit used for applying a patch',
967                      parents = [base])
968         switch(orig_head)
969         merge(base, orig_head, top)
970
971 def clone(repository, local_dir):
972     """Clone a remote repository. At the moment, just use the
973     'git-clone' script
974     """
975     if __run('git-clone', [repository, local_dir]) != 0:
976         raise GitException, 'Failed "git-clone %s %s"' \
977               % (repository, local_dir)
978
979 def modifying_revs(files, base_rev, head_rev):
980     """Return the revisions from the list modifying the given files
981     """
982     cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
983     revs = [line.strip() for line in _output_lines(cmd + files)]
984
985     return revs
986
987
988 def refspec_localpart(refspec):
989     m = re.match('^[^:]*:([^:]*)$', refspec)
990     if m:
991         return m.group(1)
992     else:
993         raise GitException, 'Cannot parse refspec "%s"' % line
994
995 def refspec_remotepart(refspec):
996     m = re.match('^([^:]*):[^:]*$', refspec)
997     if m:
998         return m.group(1)
999     else:
1000         raise GitException, 'Cannot parse refspec "%s"' % line
1001     
1002
1003 def __remotes_from_config():
1004     return config.sections_matching(r'remote\.(.*)\.url')
1005
1006 def __remotes_from_dir(dir):
1007     d = os.path.join(basedir.get(), dir)
1008     if os.path.exists(d):
1009         return os.listdir(d)
1010     else:
1011         return None
1012
1013 def remotes_list():
1014     """Return the list of remotes in the repository
1015     """
1016
1017     return Set(__remotes_from_config()) | \
1018            Set(__remotes_from_dir('remotes')) | \
1019            Set(__remotes_from_dir('branches'))
1020
1021 def remotes_local_branches(remote):
1022     """Returns the list of local branches fetched from given remote
1023     """
1024
1025     branches = []
1026     if remote in __remotes_from_config():
1027         for line in config.getall('remote.%s.fetch' % remote):
1028             branches.append(refspec_localpart(line))
1029     elif remote in __remotes_from_dir('remotes'):
1030         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1031         for line in stream:
1032             # Only consider Pull lines
1033             m = re.match('^Pull: (.*)\n$', line)
1034             if m:
1035                 branches.append(refspec_localpart(m.group(1)))
1036         stream.close()
1037     elif remote in __remotes_from_dir('branches'):
1038         # old-style branches only declare one branch
1039         branches.append('refs/heads/'+remote);
1040     else:
1041         raise GitException, 'Unknown remote "%s"' % remote
1042
1043     return branches
1044
1045 def identify_remote(branchname):
1046     """Return the name for the remote to pull the given branchname
1047     from, or None if we believe it is a local branch.
1048     """
1049
1050     for remote in remotes_list():
1051         if branchname in remotes_local_branches(remote):
1052             return remote
1053
1054     # if we get here we've found nothing, the branch is a local one
1055     return None
1056
1057 def fetch_head():
1058     """Return the git id for the tip of the parent branch as left by
1059     'git fetch'.
1060     """
1061
1062     fetch_head=None
1063     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1064     for line in stream:
1065         # Only consider lines not tagged not-for-merge
1066         m = re.match('^([^\t]*)\t\t', line)
1067         if m:
1068             if fetch_head:
1069                 raise GitException, "StGit does not support multiple FETCH_HEAD"
1070             else:
1071                 fetch_head=m.group(1)
1072     stream.close()
1073
1074     # here we are sure to have a single fetch_head
1075     return fetch_head