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