chiark / gitweb /
f6d6b43e5daa682ced028c84acf206569310ac08
[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     if recursive:
676         # this operation tracks renames but it is slower (used in
677         # general when pushing or picking patches)
678         try:
679             # use _output() to mask the verbose prints of the tool
680             _output('git-merge-recursive %s -- %s %s' % (base, head1, head2))
681         except GitException:
682             pass
683     else:
684         # the fast case where we don't track renames (used when the
685         # distance between base and heads is small, i.e. folding or
686         # synchronising patches)
687         if __run('git-read-tree -u -m --aggressive',
688                  [base, head1, head2]) != 0:
689             raise GitException, 'git-read-tree failed (local changes maybe?)'
690
691     # check the index for unmerged entries
692     files = {}
693     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
694
695     for line in _output('git-ls-files --unmerged --stage -z').split('\0'):
696         if not line:
697             continue
698
699         mode, hash, stage, path = stages_re.findall(line)[0]
700
701         if not path in files:
702             files[path] = {}
703             files[path]['1'] = ('', '')
704             files[path]['2'] = ('', '')
705             files[path]['3'] = ('', '')
706
707         files[path][stage] = (mode, hash)
708
709     # merge the unmerged files
710     errors = False
711     for path in files:
712         # remove additional files that might be generated for some
713         # newer versions of GIT
714         for suffix in [base, head1, head2]:
715             if not suffix:
716                 continue
717             fname = path + '~' + suffix
718             if os.path.exists(fname):
719                 os.remove(fname)
720
721         stages = files[path]
722         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
723                                  stages['3'][1], path, stages['1'][0],
724                                  stages['2'][0], stages['3'][0]) != 0:
725             errors = True
726
727     if errors:
728         raise GitException, 'GIT index merging failed (possible conflicts)'
729
730 def status(files = None, modified = False, new = False, deleted = False,
731            conflict = False, unknown = False, noexclude = False):
732     """Show the tree status
733     """
734     if not files:
735         files = []
736
737     cache_files = __tree_status(files, unknown = True, noexclude = noexclude)
738     all = not (modified or new or deleted or conflict or unknown)
739
740     if not all:
741         filestat = []
742         if modified:
743             filestat.append('M')
744         if new:
745             filestat.append('A')
746             filestat.append('N')
747         if deleted:
748             filestat.append('D')
749         if conflict:
750             filestat.append('C')
751         if unknown:
752             filestat.append('?')
753         cache_files = [x for x in cache_files if x[0] in filestat]
754
755     for fs in cache_files:
756         if files and not fs[1] in files:
757             continue
758         if all:
759             print '%s %s' % (fs[0], fs[1])
760         else:
761             print '%s' % fs[1]
762
763 def diff(files = None, rev1 = 'HEAD', rev2 = None, out_fd = None):
764     """Show the diff between rev1 and rev2
765     """
766     if not files:
767         files = []
768
769     if rev1 and rev2:
770         diff_str = _output(['git-diff-tree', '-p', rev1, rev2, '--'] + files)
771     elif rev1 or rev2:
772         refresh_index()
773         if rev2:
774             diff_str = _output(['git-diff-index', '-p', '-R', rev2, '--'] + files)
775         else:
776             diff_str = _output(['git-diff-index', '-p', rev1, '--'] + files)
777     else:
778         diff_str = ''
779
780     if out_fd:
781         out_fd.write(diff_str)
782     else:
783         return diff_str
784
785 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
786     """Return the diffstat between rev1 and rev2
787     """
788     if not files:
789         files = []
790
791     p=popen2.Popen3('git-apply --stat')
792     diff(files, rev1, rev2, p.tochild)
793     p.tochild.close()
794     diff_str = p.fromchild.read().rstrip()
795     if p.wait():
796         raise GitException, 'git.diffstat failed'
797     return diff_str
798
799 def files(rev1, rev2):
800     """Return the files modified between rev1 and rev2
801     """
802
803     result = ''
804     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
805         result += '%s %s\n' % tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
806
807     return result.rstrip()
808
809 def barefiles(rev1, rev2):
810     """Return the files modified between rev1 and rev2, without status info
811     """
812
813     result = ''
814     for line in _output_lines('git-diff-tree -r %s %s' % (rev1, rev2)):
815         result += '%s\n' % line.rstrip().split(' ',4)[-1].split('\t',1)[-1]
816
817     return result.rstrip()
818
819 def pretty_commit(commit_id = 'HEAD'):
820     """Return a given commit (log + diff)
821     """
822     return _output(['git-diff-tree', '--cc', '--always', '--pretty', '-r',
823                     commit_id])
824
825 def checkout(files = None, tree_id = None, force = False):
826     """Check out the given or all files
827     """
828     if not files:
829         files = []
830
831     if tree_id and __run('git-read-tree --reset', [tree_id]) != 0:
832         raise GitException, 'Failed git-read-tree --reset %s' % tree_id
833
834     checkout_cmd = 'git-checkout-index -q -u'
835     if force:
836         checkout_cmd += ' -f'
837     if len(files) == 0:
838         checkout_cmd += ' -a'
839     else:
840         checkout_cmd += ' --'
841
842     if __run(checkout_cmd, files) != 0:
843         raise GitException, 'Failed git-checkout-index'
844
845 def switch(tree_id, keep = False):
846     """Switch the tree to the given id
847     """
848     if not keep:
849         refresh_index()
850         if __run('git-read-tree -u -m', [get_head(), tree_id]) != 0:
851             raise GitException, 'git-read-tree failed (local changes maybe?)'
852
853     __set_head(tree_id)
854
855 def reset(files = None, tree_id = None, check_out = True):
856     """Revert the tree changes relative to the given tree_id. It removes
857     any local changes
858     """
859     if not tree_id:
860         tree_id = get_head()
861
862     if check_out:
863         cache_files = __tree_status(files, tree_id)
864         # files which were added but need to be removed
865         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
866
867         checkout(files, tree_id, True)
868         # checkout doesn't remove files
869         map(os.remove, rm_files)
870
871     # if the reset refers to the whole tree, switch the HEAD as well
872     if not files:
873         __set_head(tree_id)
874
875 def fetch(repository = 'origin', refspec = None):
876     """Fetches changes from the remote repository, using 'git-fetch'
877     by default.
878     """
879     # we update the HEAD
880     __clear_head_cache()
881
882     args = [repository]
883     if refspec:
884         args.append(refspec)
885
886     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
887               config.get('stgit.fetchcmd')
888     if __run(command, args) != 0:
889         raise GitException, 'Failed "%s %s"' % (command, repository)
890
891 def pull(repository = 'origin', refspec = None):
892     """Fetches changes from the remote repository, using 'git-pull'
893     by default.
894     """
895     # we update the HEAD
896     __clear_head_cache()
897
898     args = [repository]
899     if refspec:
900         args.append(refspec)
901
902     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
903               config.get('stgit.pullcmd')
904     if __run(command, args) != 0:
905         raise GitException, 'Failed "%s %s"' % (command, repository)
906
907 def repack():
908     """Repack all objects into a single pack
909     """
910     __run('git-repack -a -d -f')
911
912 def apply_patch(filename = None, diff = None, base = None,
913                 fail_dump = True):
914     """Apply a patch onto the current or given index. There must not
915     be any local changes in the tree, otherwise the command fails
916     """
917     if diff is None:
918         if filename:
919             f = file(filename)
920         else:
921             f = sys.stdin
922         diff = f.read()
923         if filename:
924             f.close()
925
926     if base:
927         orig_head = get_head()
928         switch(base)
929     else:
930         refresh_index()
931
932     try:
933         _input_str('git-apply --index', diff)
934     except GitException:
935         if base:
936             switch(orig_head)
937         if fail_dump:
938             # write the failed diff to a file
939             f = file('.stgit-failed.patch', 'w+')
940             f.write(diff)
941             f.close()
942             print >> sys.stderr, 'Diff written to the .stgit-failed.patch file'
943
944         raise
945
946     if base:
947         top = commit(message = 'temporary commit used for applying a patch',
948                      parents = [base])
949         switch(orig_head)
950         merge(base, orig_head, top)
951
952 def clone(repository, local_dir):
953     """Clone a remote repository. At the moment, just use the
954     'git-clone' script
955     """
956     if __run('git-clone', [repository, local_dir]) != 0:
957         raise GitException, 'Failed "git-clone %s %s"' \
958               % (repository, local_dir)
959
960 def modifying_revs(files, base_rev, head_rev):
961     """Return the revisions from the list modifying the given files
962     """
963     cmd = ['git-rev-list', '%s..%s' % (base_rev, head_rev), '--']
964     revs = [line.strip() for line in _output_lines(cmd + files)]
965
966     return revs
967
968
969 def refspec_localpart(refspec):
970     m = re.match('^[^:]*:([^:]*)$', refspec)
971     if m:
972         return m.group(1)
973     else:
974         raise GitException, 'Cannot parse refspec "%s"' % line
975
976 def refspec_remotepart(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
984 def __remotes_from_config():
985     return config.sections_matching(r'remote\.(.*)\.url')
986
987 def __remotes_from_dir(dir):
988     d = os.path.join(basedir.get(), dir)
989     if os.path.exists(d):
990         return os.listdir(d)
991     else:
992         return None
993
994 def remotes_list():
995     """Return the list of remotes in the repository
996     """
997
998     return Set(__remotes_from_config()) | \
999            Set(__remotes_from_dir('remotes')) | \
1000            Set(__remotes_from_dir('branches'))
1001
1002 def remotes_local_branches(remote):
1003     """Returns the list of local branches fetched from given remote
1004     """
1005
1006     branches = []
1007     if remote in __remotes_from_config():
1008         for line in config.getall('remote.%s.fetch' % remote):
1009             branches.append(refspec_localpart(line))
1010     elif remote in __remotes_from_dir('remotes'):
1011         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1012         for line in stream:
1013             # Only consider Pull lines
1014             m = re.match('^Pull: (.*)\n$', line)
1015             if m:
1016                 branches.append(refspec_localpart(m.group(1)))
1017         stream.close()
1018     elif remote in __remotes_from_dir('branches'):
1019         # old-style branches only declare one branch
1020         branches.append('refs/heads/'+remote);
1021     else:
1022         raise GitException, 'Unknown remote "%s"' % remote
1023
1024     return branches
1025
1026 def identify_remote(branchname):
1027     """Return the name for the remote to pull the given branchname
1028     from, or None if we believe it is a local branch.
1029     """
1030
1031     for remote in remotes_list():
1032         if branchname in remotes_local_branches(remote):
1033             return remote
1034
1035     # if we get here we've found nothing, the branch is a local one
1036     return None
1037
1038 def fetch_head():
1039     """Return the git id for the tip of the parent branch as left by
1040     'git fetch'.
1041     """
1042
1043     fetch_head=None
1044     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1045     for line in stream:
1046         # Only consider lines not tagged not-for-merge
1047         m = re.match('^([^\t]*)\t\t', line)
1048         if m:
1049             if fetch_head:
1050                 raise GitException, "StGit does not support multiple FETCH_HEAD"
1051             else:
1052                 fetch_head=m.group(1)
1053     stream.close()
1054
1055     # here we are sure to have a single fetch_head
1056     return fetch_head