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