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