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