chiark / gitweb /
Fix popping on non-active branches
[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     """Show the diff between rev1 and rev2
776     """
777     if not files:
778         files = []
779
780     if rev1 and rev2:
781         diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
782     elif rev1 or rev2:
783         refresh_index()
784         if rev2:
785             diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
786         else:
787             diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
788     else:
789         diff_str = ''
790
791     if out_fd:
792         out_fd.write(diff_str)
793     else:
794         return diff_str
795
796 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
797     """Return the diffstat between rev1 and rev2
798     """
799     if not files:
800         files = []
801
802     p=popen2.Popen3('git-apply --stat')
803     diff(files, rev1, rev2, p.tochild)
804     p.tochild.close()
805     diff_str = p.fromchild.read().rstrip()
806     if p.wait():
807         raise GitException, 'git.diffstat failed'
808     return diff_str
809
810 def files(rev1, rev2):
811     """Return the files modified between rev1 and rev2
812     """
813
814     result = ''
815     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
816         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
817
818     return result.rstrip()
819
820 def barefiles(rev1, rev2):
821     """Return the files modified between rev1 and rev2, without status info
822     """
823
824     result = ''
825     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
826         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
827
828     return result.rstrip()
829
830 def pretty_commit(commit_id = 'HEAD'):
831     """Return a given commit (log + diff)
832     """
833     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
834                     commit_id])
835
836 def checkout(files = None, tree_id = None, force = False):
837     """Check out the given or all files
838     """
839     if not files:
840         files = []
841
842     if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
843         raise GitException, 'Failed git-read-tree --reset %s' % tree_id
844
845     checkout_cmd = 'git-checkout-index -q -u'
846     if force:
847         checkout_cmd += ' -f'
848     if len(files) == 0:
849         checkout_cmd += ' -a'
850     else:
851         checkout_cmd += ' --'
852
853     if __run(checkout_cmd, files) != 0:
854         raise GitException, 'Failed git-checkout-index'
855
856 def switch(tree_id, keep = False):
857     """Switch the tree to the given id
858     """
859     if not keep:
860         refresh_index()
861         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
862             raise GitException, 'git-read-tree failed (local changes maybe?)'
863
864     __set_head(tree_id)
865
866 def reset(files = None, tree_id = None, check_out = True):
867     """Revert the tree changes relative to the given tree_id. It removes
868     any local changes
869     """
870     if not tree_id:
871         tree_id = get_head()
872
873     if check_out:
874         cache_files = __tree_status(files, tree_id)
875         # files which were added but need to be removed
876         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
877
878         checkout(files, tree_id, True)
879         # checkout doesn't remove files
880         map(os.remove, rm_files)
881
882     # if the reset refers to the whole tree, switch the HEAD as well
883     if not files:
884         __set_head(tree_id)
885
886 def fetch(repository = 'origin', refspec = None):
887     """Fetches changes from the remote repository, using 'git-fetch'
888     by default.
889     """
890     # we update the HEAD
891     __clear_head_cache()
892
893     args = [repository]
894     if refspec:
895         args.append(refspec)
896
897     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
898               config.get('stgit.fetchcmd')
899     if __run(command, args) != 0:
900         raise GitException, 'Failed "%s %s"' % (command, repository)
901
902 def pull(repository = 'origin', refspec = None):
903     """Fetches changes from the remote repository, using 'git-pull'
904     by default.
905     """
906     # we update the HEAD
907     __clear_head_cache()
908
909     args = [repository]
910     if refspec:
911         args.append(refspec)
912
913     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
914               config.get('stgit.pullcmd')
915     if __run(command, args) != 0:
916         raise GitException, 'Failed "%s %s"' % (command, repository)
917
918 def repack():
919     """Repack all objects into a single pack
920     """
921     __run('git-repack -a -d -f')
922
923 def apply_patch(filename = None, diff = None, base = None,
924                 fail_dump = True):
925     """Apply a patch onto the current or given index. There must not
926     be any local changes in the tree, otherwise the command fails
927     """
928     if diff is None:
929         if filename:
930             f = file(filename)
931         else:
932             f = sys.stdin
933         diff = f.read()
934         if filename:
935             f.close()
936
937     if base:
938         orig_head = get_head()
939         switch(base)
940     else:
941         refresh_index()
942
943     try:
944         _input_str('git-apply --index', diff)
945     except GitException:
946         if base:
947             switch(orig_head)
948         if fail_dump:
949             # write the failed diff to a file
950             f = file('.stgit-failed.patch', 'w+')
951             f.write(diff)
952             f.close()
953             print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
954
955         raise
956
957     if base:
958         top = commit(message = 'temporary commit used for applying a patch',
959                      parents = [base])
960         switch(orig_head)
961         merge(base, orig_head, top)
962
963 def clone(repository, local_dir):
964     """Clone a remote repository. At the moment, just use the
965     'git-clone' script
966     """
967     if __run('git-clone', [repository, local_dir]) != 0:
968         raise GitException, 'Failed "git-clone %s %s"' \
969               % (repository, local_dir)
970
971 def modifying_revs(files, base_rev, head_rev):
972     """Return the revisions from the list modifying the given files
973     """
974     cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
975     revs = [line.strip() for line in _output_lines(cmd + files)]
976
977     return revs
978
979
980 def refspec_localpart(refspec):
981     m = re.match('^[^:]*:([^:]*)$', refspec)
982     if m:
983         return m.group(1)
984     else:
985         raise GitException, 'Cannot parse refspec "%s"' % line
986
987 def refspec_remotepart(refspec):
988     m = re.match('^([^:]*):[^:]*$', refspec)
989     if m:
990         return m.group(1)
991     else:
992         raise GitException, 'Cannot parse refspec "%s"' % line
993     
994
995 def __remotes_from_config():
996     return config.sections_matching(r'remote\.(.*)\.url')
997
998 def __remotes_from_dir(dir):
999     d = os.path.join(basedir.get(), dir)
1000     if os.path.exists(d):
1001         return os.listdir(d)
1002     else:
1003         return None
1004
1005 def remotes_list():
1006     """Return the list of remotes in the repository
1007     """
1008
1009     return Set(__remotes_from_config()) | \
1010            Set(__remotes_from_dir('remotes')) | \
1011            Set(__remotes_from_dir('branches'))
1012
1013 def remotes_local_branches(remote):
1014     """Returns the list of local branches fetched from given remote
1015     """
1016
1017     branches = []
1018     if remote in __remotes_from_config():
1019         for line in config.getall('remote.%s.fetch' % remote):
1020             branches.append(refspec_localpart(line))
1021     elif remote in __remotes_from_dir('remotes'):
1022         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1023         for line in stream:
1024             # Only consider Pull lines
1025             m = re.match('^Pull: (.*)\n$', line)
1026             if m:
1027                 branches.append(refspec_localpart(m.group(1)))
1028         stream.close()
1029     elif remote in __remotes_from_dir('branches'):
1030         # old-style branches only declare one branch
1031         branches.append('refs/heads/'+remote);
1032     else:
1033         raise GitException, 'Unknown remote "%s"' % remote
1034
1035     return branches
1036
1037 def identify_remote(branchname):
1038     """Return the name for the remote to pull the given branchname
1039     from, or None if we believe it is a local branch.
1040     """
1041
1042     for remote in remotes_list():
1043         if branchname in remotes_local_branches(remote):
1044             return remote
1045
1046     # if we get here we've found nothing, the branch is a local one
1047     return None
1048
1049 def fetch_head():
1050     """Return the git id for the tip of the parent branch as left by
1051     'git fetch'.
1052     """
1053
1054     fetch_head=None
1055     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1056     for line in stream:
1057         # Only consider lines not tagged not-for-merge
1058         m = re.match('^([^\t]*)\t\t', line)
1059         if m:
1060             if fetch_head:
1061                 raise GitException, "StGit does not support multiple FETCH_HEAD"
1062             else:
1063                 fetch_head=m.group(1)
1064     stream.close()
1065
1066     # here we are sure to have a single fetch_head
1067     return fetch_head