chiark / gitweb /
b5551ded0aba040a7a7e83d3c5878e0314da42f6
[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, re, gitmergeonefile
22 from shutil import copyfile
23
24 from stgit.exception import *
25 from stgit import basedir
26 from stgit.utils import *
27 from stgit.out import *
28 from stgit.run import *
29 from stgit.config import config
30
31 # git exception class
32 class GitException(StgException):
33     pass
34
35 # When a subprocess has a problem, we want the exception to be a
36 # subclass of GitException.
37 class GitRunException(GitException):
38     pass
39 class GRun(Run):
40     exc = GitRunException
41     def __init__(self, *cmd):
42         """Initialise the Run object and insert the 'git' command name.
43         """
44         Run.__init__(self, 'git', *cmd)
45
46
47 #
48 # Classes
49 #
50
51 class Person:
52     """An author, committer, etc."""
53     def __init__(self, name = None, email = None, date = '',
54                  desc = None):
55         self.name = self.email = self.date = None
56         if name or email or date:
57             assert not desc
58             self.name = name
59             self.email = email
60             self.date = date
61         elif desc:
62             assert not (name or email or date)
63             def parse_desc(s):
64                 m = re.match(r'^(.+)<(.+)>(.*)$', s)
65                 assert m
66                 return [x.strip() or None for x in m.groups()]
67             self.name, self.email, self.date = parse_desc(desc)
68     def set_name(self, val):
69         if val:
70             self.name = val
71     def set_email(self, val):
72         if val:
73             self.email = val
74     def set_date(self, val):
75         if val:
76             self.date = val
77     def __str__(self):
78         if self.name and self.email:
79             return '%s <%s>' % (self.name, self.email)
80         else:
81             raise GitException, 'not enough identity data'
82
83 class Commit:
84     """Handle the commit objects
85     """
86     def __init__(self, id_hash):
87         self.__id_hash = id_hash
88
89         lines = GRun('cat-file', 'commit', id_hash).output_lines()
90         for i in range(len(lines)):
91             line = lines[i]
92             if not line:
93                 break # we've seen all the header fields
94             key, val = line.split(' ', 1)
95             if key == 'tree':
96                 self.__tree = val
97             elif key == 'author':
98                 self.__author = val
99             elif key == 'committer':
100                 self.__committer = val
101             else:
102                 pass # ignore other headers
103         self.__log = '\n'.join(lines[i+1:])
104
105     def get_id_hash(self):
106         return self.__id_hash
107
108     def get_tree(self):
109         return self.__tree
110
111     def get_parent(self):
112         parents = self.get_parents()
113         if parents:
114             return parents[0]
115         else:
116             return None
117
118     def get_parents(self):
119         return GRun('rev-list', '--parents', '--max-count=1', self.__id_hash
120                     ).output_one_line().split()[1:]
121
122     def get_author(self):
123         return self.__author
124
125     def get_committer(self):
126         return self.__committer
127
128     def get_log(self):
129         return self.__log
130
131     def __str__(self):
132         return self.get_id_hash()
133
134 # dictionary of Commit objects, used to avoid multiple calls to git
135 __commits = dict()
136
137 #
138 # Functions
139 #
140
141 def get_commit(id_hash):
142     """Commit objects factory. Save/look-up them in the __commits
143     dictionary
144     """
145     global __commits
146
147     if id_hash in __commits:
148         return __commits[id_hash]
149     else:
150         commit = Commit(id_hash)
151         __commits[id_hash] = commit
152         return commit
153
154 def get_conflicts():
155     """Return the list of file conflicts
156     """
157     conflicts_file = os.path.join(basedir.get(), 'conflicts')
158     if os.path.isfile(conflicts_file):
159         f = file(conflicts_file)
160         names = [line.strip() for line in f.readlines()]
161         f.close()
162         return names
163     else:
164         return None
165
166 def exclude_files():
167     files = [os.path.join(basedir.get(), 'info', 'exclude')]
168     user_exclude = config.get('core.excludesfile')
169     if user_exclude:
170         files.append(user_exclude)
171     return files
172
173 def ls_files(files, tree = None, full_name = True):
174     """Return the files known to GIT or raise an error otherwise. It also
175     converts the file to the full path relative the the .git directory.
176     """
177     if not files:
178         return []
179
180     args = []
181     if tree:
182         args.append('--with-tree=%s' % tree)
183     if full_name:
184         args.append('--full-name')
185     args.append('--')
186     args.extend(files)
187     try:
188         return GRun('ls-files', '--error-unmatch', *args).output_lines()
189     except GitRunException:
190         # just hide the details of the 'git ls-files' command we use
191         raise GitException, \
192             'Some of the given paths are either missing or not known to GIT'
193
194 def tree_status(files = None, tree_id = 'HEAD', unknown = False,
195                   noexclude = True, verbose = False, diff_flags = []):
196     """Get the status of all changed files, or of a selected set of
197     files. Returns a list of pairs - (status, filename).
198
199     If 'not files', it will check all files, and optionally all
200     unknown files.  If 'files' is a list, it will only check the files
201     in the list.
202     """
203     assert not files or not unknown
204
205     if verbose:
206         out.start('Checking for changes in the working directory')
207
208     refresh_index()
209
210     cache_files = []
211
212     # unknown files
213     if unknown:
214         cmd = ['ls-files', '-z', '--others', '--directory',
215                '--no-empty-directory']
216         if not noexclude:
217             cmd += ['--exclude=%s' % s for s in
218                     ['*.[ao]', '*.pyc', '.*', '*~', '#*', 'TAGS', 'tags']]
219             cmd += ['--exclude-per-directory=.gitignore']
220             cmd += ['--exclude-from=%s' % fn
221                     for fn in exclude_files()
222                     if os.path.exists(fn)]
223
224         lines = GRun(*cmd).raw_output().split('\0')
225         cache_files += [('?', line) for line in lines if line]
226
227     # conflicted files
228     conflicts = get_conflicts()
229     if not conflicts:
230         conflicts = []
231     cache_files += [('C', filename) for filename in conflicts
232                     if not files or filename in files]
233
234     # the rest
235     args = diff_flags + [tree_id]
236     if files:
237         args += ['--'] + files
238     for line in GRun('diff-index', *args).output_lines():
239         fs = tuple(line.rstrip().split(' ',4)[-1].split('\t',1))
240         if fs[1] not in conflicts:
241             cache_files.append(fs)
242
243     if verbose:
244         out.done()
245
246     return cache_files
247
248 def local_changes(verbose = True):
249     """Return true if there are local changes in the tree
250     """
251     return len(tree_status(verbose = verbose)) != 0
252
253 def get_heads():
254     heads = []
255     hr = re.compile(r'^[0-9a-f]{40} refs/heads/(.+)$')
256     for line in GRun('show-ref', '--heads').output_lines():
257         m = hr.match(line)
258         heads.append(m.group(1))
259     return heads
260
261 # HEAD value cached
262 __head = None
263
264 def get_head():
265     """Verifies the HEAD and returns the SHA1 id that represents it
266     """
267     global __head
268
269     if not __head:
270         __head = rev_parse('HEAD')
271     return __head
272
273 class DetachedHeadException(GitException):
274     def __init__(self):
275         GitException.__init__(self, 'Not on any branch')
276
277 def get_head_file():
278     """Return the name of the file pointed to by the HEAD symref.
279     Throw an exception if HEAD is detached."""
280     try:
281         return strip_prefix(
282             'refs/heads/', GRun('symbolic-ref', '-q', 'HEAD'
283                                 ).output_one_line())
284     except GitRunException:
285         raise DetachedHeadException()
286
287 def set_head_file(ref):
288     """Resets HEAD to point to a new ref
289     """
290     # head cache flushing is needed since we might have a different value
291     # in the new head
292     __clear_head_cache()
293     try:
294         GRun('symbolic-ref', 'HEAD', 'refs/heads/%s' % ref).run()
295     except GitRunException:
296         raise GitException, 'Could not set head to "%s"' % ref
297
298 def set_ref(ref, val):
299     """Point ref at a new commit object."""
300     try:
301         GRun('update-ref', ref, val).run()
302     except GitRunException:
303         raise GitException, 'Could not update %s to "%s".' % (ref, val)
304
305 def set_branch(branch, val):
306     set_ref('refs/heads/%s' % branch, val)
307
308 def __set_head(val):
309     """Sets the HEAD value
310     """
311     global __head
312
313     if not __head or __head != val:
314         set_ref('HEAD', val)
315         __head = val
316
317     # only allow SHA1 hashes
318     assert(len(__head) == 40)
319
320 def __clear_head_cache():
321     """Sets the __head to None so that a re-read is forced
322     """
323     global __head
324
325     __head = None
326
327 def refresh_index():
328     """Refresh index with stat() information from the working directory.
329     """
330     GRun('update-index', '-q', '--unmerged', '--refresh').run()
331
332 def rev_parse(git_id):
333     """Parse the string and return a verified SHA1 id
334     """
335     try:
336         return GRun('rev-parse', '--verify', git_id
337                     ).discard_stderr().output_one_line()
338     except GitRunException:
339         raise GitException, 'Unknown revision: %s' % git_id
340
341 def ref_exists(ref):
342     try:
343         rev_parse(ref)
344         return True
345     except GitException:
346         return False
347
348 def branch_exists(branch):
349     return ref_exists('refs/heads/%s' % branch)
350
351 def create_branch(new_branch, tree_id = None):
352     """Create a new branch in the git repository
353     """
354     if branch_exists(new_branch):
355         raise GitException, 'Branch "%s" already exists' % new_branch
356
357     current_head = get_head()
358     set_head_file(new_branch)
359     __set_head(current_head)
360
361     # a checkout isn't needed if new branch points to the current head
362     if tree_id:
363         switch(tree_id)
364
365     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
366         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
367
368 def switch_branch(new_branch):
369     """Switch to a git branch
370     """
371     global __head
372
373     if not branch_exists(new_branch):
374         raise GitException, 'Branch "%s" does not exist' % new_branch
375
376     tree_id = rev_parse('refs/heads/%s^{commit}' % new_branch)
377     if tree_id != get_head():
378         refresh_index()
379         try:
380             GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
381         except GitRunException:
382             raise GitException, 'read-tree failed (local changes maybe?)'
383         __head = tree_id
384     set_head_file(new_branch)
385
386     if os.path.isfile(os.path.join(basedir.get(), 'MERGE_HEAD')):
387         os.remove(os.path.join(basedir.get(), 'MERGE_HEAD'))
388
389 def delete_ref(ref):
390     if not ref_exists(ref):
391         raise GitException, '%s does not exist' % ref
392     sha1 = GRun('show-ref', '-s', ref).output_one_line()
393     try:
394         GRun('update-ref', '-d', ref, sha1).run()
395     except GitRunException:
396         raise GitException, 'Failed to delete ref %s' % ref
397
398 def delete_branch(name):
399     delete_ref('refs/heads/%s' % name)
400
401 def rename_ref(from_ref, to_ref):
402     if not ref_exists(from_ref):
403         raise GitException, '"%s" does not exist' % from_ref
404     if ref_exists(to_ref):
405         raise GitException, '"%s" already exists' % to_ref
406
407     sha1 = GRun('show-ref', '-s', from_ref).output_one_line()
408     try:
409         GRun('update-ref', to_ref, sha1, '0'*40).run()
410     except GitRunException:
411         raise GitException, 'Failed to create new ref %s' % to_ref
412     try:
413         GRun('update-ref', '-d', from_ref, sha1).run()
414     except GitRunException:
415         raise GitException, 'Failed to delete ref %s' % from_ref
416
417 def rename_branch(from_name, to_name):
418     """Rename a git branch."""
419     rename_ref('refs/heads/%s' % from_name, 'refs/heads/%s' % to_name)
420     try:
421         if get_head_file() == from_name:
422             set_head_file(to_name)
423     except DetachedHeadException:
424         pass # detached HEAD, so the renamee can't be the current branch
425     reflog_dir = os.path.join(basedir.get(), 'logs', 'refs', 'heads')
426     if os.path.exists(reflog_dir) \
427            and os.path.exists(os.path.join(reflog_dir, from_name)):
428         rename(reflog_dir, from_name, to_name)
429
430 def add(names):
431     """Add the files or recursively add the directory contents
432     """
433     # generate the file list
434     files = []
435     for i in names:
436         if not os.path.exists(i):
437             raise GitException, 'Unknown file or directory: %s' % i
438
439         if os.path.isdir(i):
440             # recursive search. We only add files
441             for root, dirs, local_files in os.walk(i):
442                 for name in [os.path.join(root, f) for f in local_files]:
443                     if os.path.isfile(name):
444                         files.append(os.path.normpath(name))
445         elif os.path.isfile(i):
446             files.append(os.path.normpath(i))
447         else:
448             raise GitException, '%s is not a file or directory' % i
449
450     if files:
451         try:
452             GRun('update-index', '--add', '--').xargs(files)
453         except GitRunException:
454             raise GitException, 'Unable to add file'
455
456 def __copy_single(source, target, target2=''):
457     """Copy file or dir named 'source' to name target+target2"""
458
459     # "source" (file or dir) must match one or more git-controlled file
460     realfiles = GRun('ls-files', source).output_lines()
461     if len(realfiles) == 0:
462         raise GitException, '"%s" matches no git-controled files' % source
463
464     if os.path.isdir(source):
465         # physically copy the files, and record them to add them in one run
466         newfiles = []
467         re_string='^'+source+'/(.*)$'
468         prefix_regexp = re.compile(re_string)
469         for f in [f.strip() for f in realfiles]:
470             m = prefix_regexp.match(f)
471             if not m:
472                 raise Exception, '"%s" does not match "%s"' % (f, re_string)
473             newname = target+target2+'/'+m.group(1)
474             if not os.path.exists(os.path.dirname(newname)):
475                 os.makedirs(os.path.dirname(newname))
476             copyfile(f, newname)
477             newfiles.append(newname)
478
479         add(newfiles)
480     else: # files, symlinks, ...
481         newname = target+target2
482         copyfile(source, newname)
483         add([newname])
484
485
486 def copy(filespecs, target):
487     if os.path.isdir(target):
488         # target is a directory: copy each entry on the command line,
489         # with the same name, into the target
490         target = target.rstrip('/')
491         
492         # first, check that none of the children of the target
493         # matching the command line aleady exist
494         for filespec in filespecs:
495             entry = target+ '/' + os.path.basename(filespec.rstrip('/'))
496             if os.path.exists(entry):
497                 raise GitException, 'Target "%s" already exists' % entry
498         
499         for filespec in filespecs:
500             filespec = filespec.rstrip('/')
501             basename = '/' + os.path.basename(filespec)
502             __copy_single(filespec, target, basename)
503
504     elif os.path.exists(target):
505         raise GitException, 'Target "%s" exists but is not a directory' % target
506     elif len(filespecs) != 1:
507         raise GitException, 'Cannot copy more than one file to non-directory'
508
509     else:
510         # at this point: len(filespecs)==1 and target does not exist
511
512         # check target directory
513         targetdir = os.path.dirname(target)
514         if targetdir != '' and not os.path.isdir(targetdir):
515             raise GitException, 'Target directory "%s" does not exist' % targetdir
516
517         __copy_single(filespecs[0].rstrip('/'), target)
518         
519
520 def rm(files, force = False):
521     """Remove a file from the repository
522     """
523     if not force:
524         for f in files:
525             if os.path.exists(f):
526                 raise GitException, '%s exists. Remove it first' %f
527         if files:
528             GRun('update-index', '--remove', '--').xargs(files)
529     else:
530         if files:
531             GRun('update-index', '--force-remove', '--').xargs(files)
532
533 # Persons caching
534 __user = None
535 __author = None
536 __committer = None
537
538 def user():
539     """Return the user information.
540     """
541     global __user
542     if not __user:
543         name=config.get('user.name')
544         email=config.get('user.email')
545         __user = Person(name, email)
546     return __user;
547
548 def author():
549     """Return the author information.
550     """
551     global __author
552     if not __author:
553         try:
554             # the environment variables take priority over config
555             try:
556                 date = os.environ['GIT_AUTHOR_DATE']
557             except KeyError:
558                 date = ''
559             __author = Person(os.environ['GIT_AUTHOR_NAME'],
560                               os.environ['GIT_AUTHOR_EMAIL'],
561                               date)
562         except KeyError:
563             __author = user()
564     return __author
565
566 def committer():
567     """Return the author information.
568     """
569     global __committer
570     if not __committer:
571         try:
572             # the environment variables take priority over config
573             try:
574                 date = os.environ['GIT_COMMITTER_DATE']
575             except KeyError:
576                 date = ''
577             __committer = Person(os.environ['GIT_COMMITTER_NAME'],
578                                  os.environ['GIT_COMMITTER_EMAIL'],
579                                  date)
580         except KeyError:
581             __committer = user()
582     return __committer
583
584 def update_cache(files = None, force = False):
585     """Update the cache information for the given files
586     """
587     cache_files = tree_status(files, verbose = False)
588
589     # everything is up-to-date
590     if len(cache_files) == 0:
591         return False
592
593     # check for unresolved conflicts
594     if not force and [x for x in cache_files
595                       if x[0] not in ['M', 'N', 'A', 'D']]:
596         raise GitException, 'Updating cache failed: unresolved conflicts'
597
598     # update the cache
599     add_files = [x[1] for x in cache_files if x[0] in ['N', 'A']]
600     rm_files =  [x[1] for x in cache_files if x[0] in ['D']]
601     m_files =   [x[1] for x in cache_files if x[0] in ['M']]
602
603     GRun('update-index', '--add', '--').xargs(add_files)
604     GRun('update-index', '--force-remove', '--').xargs(rm_files)
605     GRun('update-index', '--').xargs(m_files)
606
607     return True
608
609 def commit(message, files = None, parents = None, allowempty = False,
610            cache_update = True, tree_id = None, set_head = False,
611            author_name = None, author_email = None, author_date = None,
612            committer_name = None, committer_email = None):
613     """Commit the current tree to repository
614     """
615     if not parents:
616         parents = []
617
618     # Get the tree status
619     if cache_update and parents != []:
620         changes = update_cache(files)
621         if not changes and not allowempty:
622             raise GitException, 'No changes to commit'
623
624     # get the commit message
625     if not message:
626         message = '\n'
627     elif message[-1:] != '\n':
628         message += '\n'
629
630     # write the index to repository
631     if tree_id == None:
632         tree_id = GRun('write-tree').output_one_line()
633         set_head = True
634
635     # the commit
636     env = {}
637     if author_name:
638         env['GIT_AUTHOR_NAME'] = author_name
639     if author_email:
640         env['GIT_AUTHOR_EMAIL'] = author_email
641     if author_date:
642         env['GIT_AUTHOR_DATE'] = author_date
643     if committer_name:
644         env['GIT_COMMITTER_NAME'] = committer_name
645     if committer_email:
646         env['GIT_COMMITTER_EMAIL'] = committer_email
647     commit_id = GRun('commit-tree', tree_id,
648                      *sum([['-p', p] for p in parents], [])
649                      ).env(env).raw_input(message).output_one_line()
650     if set_head:
651         __set_head(commit_id)
652
653     return commit_id
654
655 def apply_diff(rev1, rev2, check_index = True, files = None):
656     """Apply the diff between rev1 and rev2 onto the current
657     index. This function doesn't need to raise an exception since it
658     is only used for fast-pushing a patch. If this operation fails,
659     the pushing would fall back to the three-way merge.
660     """
661     if check_index:
662         index_opt = ['--index']
663     else:
664         index_opt = []
665
666     if not files:
667         files = []
668
669     diff_str = diff(files, rev1, rev2)
670     if diff_str:
671         try:
672             GRun('apply', *index_opt).raw_input(
673                 diff_str).discard_stderr().no_output()
674         except GitRunException:
675             return False
676
677     return True
678
679 def merge(base, head1, head2, recursive = False):
680     """Perform a 3-way merge between base, head1 and head2 into the
681     local tree
682     """
683     refresh_index()
684
685     err_output = None
686     if recursive:
687         # this operation tracks renames but it is slower (used in
688         # general when pushing or picking patches)
689         try:
690             # discard output to mask the verbose prints of the tool
691             GRun('merge-recursive', base, '--', head1, head2
692                  ).discard_output()
693         except GitRunException, ex:
694             err_output = str(ex)
695             pass
696     else:
697         # the fast case where we don't track renames (used when the
698         # distance between base and heads is small, i.e. folding or
699         # synchronising patches)
700         try:
701             GRun('read-tree', '-u', '-m', '--aggressive',
702                  base, head1, head2).run()
703         except GitRunException:
704             raise GitException, 'read-tree failed (local changes maybe?)'
705
706     # check the index for unmerged entries
707     files = {}
708     stages_re = re.compile('^([0-7]+) ([0-9a-f]{40}) ([1-3])\t(.*)$', re.S)
709
710     for line in GRun('ls-files', '--unmerged', '--stage', '-z'
711                      ).raw_output().split('\0'):
712         if not line:
713             continue
714
715         mode, hash, stage, path = stages_re.findall(line)[0]
716
717         if not path in files:
718             files[path] = {}
719             files[path]['1'] = ('', '')
720             files[path]['2'] = ('', '')
721             files[path]['3'] = ('', '')
722
723         files[path][stage] = (mode, hash)
724
725     if err_output and not files:
726         # if no unmerged files, there was probably a different type of
727         # error and we have to abort the merge
728         raise GitException, err_output
729
730     # merge the unmerged files
731     errors = False
732     for path in files:
733         # remove additional files that might be generated for some
734         # newer versions of GIT
735         for suffix in [base, head1, head2]:
736             if not suffix:
737                 continue
738             fname = path + '~' + suffix
739             if os.path.exists(fname):
740                 os.remove(fname)
741
742         stages = files[path]
743         if gitmergeonefile.merge(stages['1'][1], stages['2'][1],
744                                  stages['3'][1], path, stages['1'][0],
745                                  stages['2'][0], stages['3'][0]) != 0:
746             errors = True
747
748     if errors:
749         raise GitException, 'GIT index merging failed (possible conflicts)'
750
751 def diff(files = None, rev1 = 'HEAD', rev2 = None, diff_flags = []):
752     """Show the diff between rev1 and rev2
753     """
754     if not files:
755         files = []
756
757     if rev1 and rev2:
758         return GRun('diff-tree', '-p',
759                     *(diff_flags + [rev1, rev2, '--'] + files)).raw_output()
760     elif rev1 or rev2:
761         refresh_index()
762         if rev2:
763             return GRun('diff-index', '-p', '-R',
764                         *(diff_flags + [rev2, '--'] + files)).raw_output()
765         else:
766             return GRun('diff-index', '-p',
767                         *(diff_flags + [rev1, '--'] + files)).raw_output()
768     else:
769         return ''
770
771 # TODO: take another parameter representing a diff string as we
772 # usually invoke git.diff() form the calling functions
773 def diffstat(files = None, rev1 = 'HEAD', rev2 = None):
774     """Return the diffstat between rev1 and rev2."""
775     return GRun('apply', '--stat', '--summary'
776                 ).raw_input(diff(files, rev1, rev2)).raw_output()
777
778 def files(rev1, rev2, diff_flags = []):
779     """Return the files modified between rev1 and rev2
780     """
781
782     result = []
783     for line in GRun('diff-tree', *(diff_flags + ['-r', rev1, rev2])
784                      ).output_lines():
785         result.append('%s %s' % tuple(line.split(' ', 4)[-1].split('\t', 1)))
786
787     return '\n'.join(result)
788
789 def barefiles(rev1, rev2):
790     """Return the files modified between rev1 and rev2, without status info
791     """
792
793     result = []
794     for line in GRun('diff-tree', '-r', rev1, rev2).output_lines():
795         result.append(line.split(' ', 4)[-1].split('\t', 1)[-1])
796
797     return '\n'.join(result)
798
799 def pretty_commit(commit_id = 'HEAD', diff_flags = []):
800     """Return a given commit (log + diff)
801     """
802     return GRun('diff-tree',
803                 *(diff_flags
804                   + ['--cc', '--always', '--pretty', '-r', commit_id])
805                 ).raw_output()
806
807 def checkout(files = None, tree_id = None, force = False):
808     """Check out the given or all files
809     """
810     if tree_id:
811         try:
812             GRun('read-tree', '--reset', tree_id).run()
813         except GitRunException:
814             raise GitException, 'Failed "git read-tree" --reset %s' % tree_id
815
816     cmd = ['checkout-index', '-q', '-u']
817     if force:
818         cmd.append('-f')
819     if files:
820         GRun(*(cmd + ['--'])).xargs(files)
821     else:
822         GRun(*(cmd + ['-a'])).run()
823
824 def switch(tree_id, keep = False):
825     """Switch the tree to the given id
826     """
827     if keep:
828         # only update the index while keeping the local changes
829         GRun('read-tree', tree_id).run()
830     else:
831         refresh_index()
832         try:
833             GRun('read-tree', '-u', '-m', get_head(), tree_id).run()
834         except GitRunException:
835             raise GitException, 'read-tree failed (local changes maybe?)'
836
837     __set_head(tree_id)
838
839 def reset(files = None, tree_id = None, check_out = True):
840     """Revert the tree changes relative to the given tree_id. It removes
841     any local changes
842     """
843     if not tree_id:
844         tree_id = get_head()
845
846     if check_out:
847         cache_files = tree_status(files, tree_id)
848         # files which were added but need to be removed
849         rm_files =  [x[1] for x in cache_files if x[0] in ['A']]
850
851         checkout(files, tree_id, True)
852         # checkout doesn't remove files
853         map(os.remove, rm_files)
854
855     # if the reset refers to the whole tree, switch the HEAD as well
856     if not files:
857         __set_head(tree_id)
858
859 def fetch(repository = 'origin', refspec = None):
860     """Fetches changes from the remote repository, using 'git fetch'
861     by default.
862     """
863     # we update the HEAD
864     __clear_head_cache()
865
866     args = [repository]
867     if refspec:
868         args.append(refspec)
869
870     command = config.get('branch.%s.stgit.fetchcmd' % get_head_file()) or \
871               config.get('stgit.fetchcmd')
872     Run(*(command.split() + args)).run()
873
874 def pull(repository = 'origin', refspec = None):
875     """Fetches changes from the remote repository, using 'git pull'
876     by default.
877     """
878     # we update the HEAD
879     __clear_head_cache()
880
881     args = [repository]
882     if refspec:
883         args.append(refspec)
884
885     command = config.get('branch.%s.stgit.pullcmd' % get_head_file()) or \
886               config.get('stgit.pullcmd')
887     Run(*(command.split() + args)).run()
888
889 def rebase(tree_id = None):
890     """Rebase the current tree to the give tree_id. The tree_id
891     argument may be something other than a GIT id if an external
892     command is invoked.
893     """
894     command = config.get('branch.%s.stgit.rebasecmd' % get_head_file()) \
895                 or config.get('stgit.rebasecmd')
896     if tree_id:
897         args = [tree_id]
898     elif command:
899         args = []
900     else:
901         raise GitException, 'Default rebasing requires a commit id'
902     if command:
903         # clear the HEAD cache as the custom rebase command will update it
904         __clear_head_cache()
905         Run(*(command.split() + args)).run()
906     else:
907         # default rebasing
908         reset(tree_id = tree_id)
909
910 def repack():
911     """Repack all objects into a single pack
912     """
913     GRun('repack', '-a', '-d', '-f').run()
914
915 def apply_patch(filename = None, diff = None, base = None,
916                 fail_dump = True):
917     """Apply a patch onto the current or given index. There must not
918     be any local changes in the tree, otherwise the command fails
919     """
920     if diff is None:
921         if filename:
922             f = file(filename)
923         else:
924             f = sys.stdin
925         diff = f.read()
926         if filename:
927             f.close()
928
929     if base:
930         orig_head = get_head()
931         switch(base)
932     else:
933         refresh_index()
934
935     try:
936         GRun('apply', '--index').raw_input(diff).no_output()
937     except GitRunException:
938         if base:
939             switch(orig_head)
940         if fail_dump:
941             # write the failed diff to a file
942             f = file('.stgit-failed.patch', 'w+')
943             f.write(diff)
944             f.close()
945             out.warn('Diff written to the .stgit-failed.patch file')
946
947         raise
948
949     if base:
950         top = commit(message = 'temporary commit used for applying a patch',
951                      parents = [base])
952         switch(orig_head)
953         merge(base, orig_head, top)
954
955 def clone(repository, local_dir):
956     """Clone a remote repository. At the moment, just use the
957     'git clone' script
958     """
959     GRun('clone', repository, local_dir).run()
960
961 def modifying_revs(files, base_rev, head_rev):
962     """Return the revisions from the list modifying the given files."""
963     return GRun('rev-list', '%s..%s' % (base_rev, head_rev), '--', *files
964                 ).output_lines()
965
966 def refspec_localpart(refspec):
967     m = re.match('^[^:]*:([^:]*)$', refspec)
968     if m:
969         return m.group(1)
970     else:
971         raise GitException, 'Cannot parse refspec "%s"' % line
972
973 def refspec_remotepart(refspec):
974     m = re.match('^([^:]*):[^:]*$', refspec)
975     if m:
976         return m.group(1)
977     else:
978         raise GitException, 'Cannot parse refspec "%s"' % line
979     
980
981 def __remotes_from_config():
982     return config.sections_matching(r'remote\.(.*)\.url')
983
984 def __remotes_from_dir(dir):
985     d = os.path.join(basedir.get(), dir)
986     if os.path.exists(d):
987         return os.listdir(d)
988     else:
989         return []
990
991 def remotes_list():
992     """Return the list of remotes in the repository
993     """
994     return (set(__remotes_from_config())
995             | set(__remotes_from_dir('remotes'))
996             | set(__remotes_from_dir('branches')))
997
998 def remotes_local_branches(remote):
999     """Returns the list of local branches fetched from given remote
1000     """
1001
1002     branches = []
1003     if remote in __remotes_from_config():
1004         for line in config.getall('remote.%s.fetch' % remote):
1005             branches.append(refspec_localpart(line))
1006     elif remote in __remotes_from_dir('remotes'):
1007         stream = open(os.path.join(basedir.get(), 'remotes', remote), 'r')
1008         for line in stream:
1009             # Only consider Pull lines
1010             m = re.match('^Pull: (.*)\n$', line)
1011             if m:
1012                 branches.append(refspec_localpart(m.group(1)))
1013         stream.close()
1014     elif remote in __remotes_from_dir('branches'):
1015         # old-style branches only declare one branch
1016         branches.append('refs/heads/'+remote);
1017     else:
1018         raise GitException, 'Unknown remote "%s"' % remote
1019
1020     return branches
1021
1022 def identify_remote(branchname):
1023     """Return the name for the remote to pull the given branchname
1024     from, or None if we believe it is a local branch.
1025     """
1026
1027     for remote in remotes_list():
1028         if branchname in remotes_local_branches(remote):
1029             return remote
1030
1031     # if we get here we've found nothing, the branch is a local one
1032     return None
1033
1034 def fetch_head():
1035     """Return the git id for the tip of the parent branch as left by
1036     'git fetch'.
1037     """
1038
1039     fetch_head=None
1040     stream = open(os.path.join(basedir.get(), 'FETCH_HEAD'), "r")
1041     for line in stream:
1042         # Only consider lines not tagged not-for-merge
1043         m = re.match('^([^\t]*)\t\t', line)
1044         if m:
1045             if fetch_head:
1046                 raise GitException, 'StGit does not support multiple FETCH_HEAD'
1047             else:
1048                 fetch_head=m.group(1)
1049     stream.close()
1050
1051     if not fetch_head:
1052         out.warn('No for-merge remote head found in FETCH_HEAD')
1053
1054     # here we are sure to have a single fetch_head
1055     return fetch_head
1056
1057 def all_refs():
1058     """Return a list of all refs in the current repository.
1059     """
1060
1061     return [line.split()[1] for line in GRun('show-ref').output_lines()]