chiark / gitweb /
Generate binary diffs by default
[stgit] / stgit / lib / git.py
1 """A Python class hierarchy wrapping a git repository and its
2 contents."""
3
4 import os, os.path, re
5 from datetime import datetime, timedelta, tzinfo
6
7 from stgit import exception, run, utils
8 from stgit.config import config
9
10 class Immutable(object):
11     """I{Immutable} objects cannot be modified once created. Any
12     modification methods will return a new object, leaving the
13     original object as it was.
14
15     The reason for this is that we want to be able to represent git
16     objects, which are immutable, and want to be able to create new
17     git objects that are just slight modifications of other git
18     objects. (Such as, for example, modifying the commit message of a
19     commit object while leaving the rest of it intact. This involves
20     creating a whole new commit object that's exactly like the old one
21     except for the commit message.)
22
23     The L{Immutable} class doesn't actually enforce immutability --
24     that is up to the individual immutable subclasses. It just serves
25     as documentation."""
26
27 class RepositoryException(exception.StgException):
28     """Base class for all exceptions due to failed L{Repository}
29     operations."""
30
31 class BranchException(exception.StgException):
32     """Exception raised by failed L{Branch} operations."""
33
34 class DateException(exception.StgException):
35     """Exception raised when a date+time string could not be parsed."""
36     def __init__(self, string, type):
37         exception.StgException.__init__(
38             self, '"%s" is not a valid %s' % (string, type))
39
40 class DetachedHeadException(RepositoryException):
41     """Exception raised when HEAD is detached (that is, there is no
42     current branch)."""
43     def __init__(self):
44         RepositoryException.__init__(self, 'Not on any branch')
45
46 class Repr(object):
47     """Utility class that defines C{__reps__} in terms of C{__str__}."""
48     def __repr__(self):
49         return str(self)
50
51 class NoValue(object):
52     """A handy default value that is guaranteed to be distinct from any
53     real argument value."""
54     pass
55
56 def make_defaults(defaults):
57     def d(val, attr, default_fun = lambda: None):
58         if val != NoValue:
59             return val
60         elif defaults != NoValue:
61             return getattr(defaults, attr)
62         else:
63             return default_fun()
64     return d
65
66 class TimeZone(tzinfo, Repr):
67     """A simple time zone class for static offsets from UTC. (We have to
68     define our own since Python's standard library doesn't define any
69     time zone classes.)"""
70     def __init__(self, tzstring):
71         m = re.match(r'^([+-])(\d{2}):?(\d{2})$', tzstring)
72         if not m:
73             raise DateException(tzstring, 'time zone')
74         sign = int(m.group(1) + '1')
75         try:
76             self.__offset = timedelta(hours = sign*int(m.group(2)),
77                                       minutes = sign*int(m.group(3)))
78         except OverflowError:
79             raise DateException(tzstring, 'time zone')
80         self.__name = tzstring
81     def utcoffset(self, dt):
82         return self.__offset
83     def tzname(self, dt):
84         return self.__name
85     def dst(self, dt):
86         return timedelta(0)
87     def __str__(self):
88         return self.__name
89
90 class Date(Immutable, Repr):
91     """Represents a timestamp used in git commits."""
92     def __init__(self, datestring):
93         # Try git-formatted date.
94         m = re.match(r'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring)
95         if m:
96             try:
97                 self.__time = datetime.fromtimestamp(int(m.group(1)),
98                                                      TimeZone(m.group(2)))
99             except ValueError:
100                 raise DateException(datestring, 'date')
101             return
102
103         # Try iso-formatted date.
104         m = re.match(r'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+'
105                      + r'([+-]\d\d:?\d\d)$', datestring)
106         if m:
107             try:
108                 self.__time = datetime(
109                     *[int(m.group(i + 1)) for i in xrange(6)],
110                     **{'tzinfo': TimeZone(m.group(7))})
111             except ValueError:
112                 raise DateException(datestring, 'date')
113             return
114
115         raise DateException(datestring, 'date')
116     def __str__(self):
117         return self.isoformat()
118     def isoformat(self):
119         """Human-friendly ISO 8601 format."""
120         return '%s %s' % (self.__time.replace(tzinfo = None).isoformat(' '),
121                           self.__time.tzinfo)
122     @classmethod
123     def maybe(cls, datestring):
124         """Return a new object initialized with the argument if it contains a
125         value (otherwise, just return the argument)."""
126         if datestring in [None, NoValue]:
127             return datestring
128         return cls(datestring)
129
130 class Person(Immutable, Repr):
131     """Represents an author or committer in a git commit object. Contains
132     name, email and timestamp."""
133     def __init__(self, name = NoValue, email = NoValue,
134                  date = NoValue, defaults = NoValue):
135         d = make_defaults(defaults)
136         self.__name = d(name, 'name')
137         self.__email = d(email, 'email')
138         self.__date = d(date, 'date')
139         assert isinstance(self.__date, Date) or self.__date in [None, NoValue]
140     name = property(lambda self: self.__name)
141     email = property(lambda self: self.__email)
142     name_email = property(lambda self: '%s <%s>' % (self.name, self.email))
143     date = property(lambda self: self.__date)
144     def set_name(self, name):
145         return type(self)(name = name, defaults = self)
146     def set_email(self, email):
147         return type(self)(email = email, defaults = self)
148     def set_date(self, date):
149         return type(self)(date = date, defaults = self)
150     def __str__(self):
151         return '%s %s' % (self.name_email, self.date)
152     @classmethod
153     def parse(cls, s):
154         m = re.match(r'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s)
155         assert m
156         name = m.group(1).strip()
157         email = m.group(2)
158         date = Date(m.group(3))
159         return cls(name, email, date)
160     @classmethod
161     def user(cls):
162         if not hasattr(cls, '__user'):
163             cls.__user = cls(name = config.get('user.name'),
164                              email = config.get('user.email'))
165         return cls.__user
166     @classmethod
167     def author(cls):
168         if not hasattr(cls, '__author'):
169             cls.__author = cls(
170                 name = os.environ.get('GIT_AUTHOR_NAME', NoValue),
171                 email = os.environ.get('GIT_AUTHOR_EMAIL', NoValue),
172                 date = Date.maybe(os.environ.get('GIT_AUTHOR_DATE', NoValue)),
173                 defaults = cls.user())
174         return cls.__author
175     @classmethod
176     def committer(cls):
177         if not hasattr(cls, '__committer'):
178             cls.__committer = cls(
179                 name = os.environ.get('GIT_COMMITTER_NAME', NoValue),
180                 email = os.environ.get('GIT_COMMITTER_EMAIL', NoValue),
181                 date = Date.maybe(
182                     os.environ.get('GIT_COMMITTER_DATE', NoValue)),
183                 defaults = cls.user())
184         return cls.__committer
185
186 class GitObject(Immutable, Repr):
187     """Base class for all git objects. One git object is represented by at
188     most one C{GitObject}, which makes it possible to compare them
189     using normal Python object comparison; it also ensures we don't
190     waste more memory than necessary."""
191
192 class BlobData(Immutable, Repr):
193     """Represents the data contents of a git blob object."""
194     def __init__(self, string):
195         self.__string = str(string)
196     str = property(lambda self: self.__string)
197     def commit(self, repository):
198         """Commit the blob.
199         @return: The committed blob
200         @rtype: L{Blob}"""
201         sha1 = repository.run(['git', 'hash-object', '-w', '--stdin']
202                               ).raw_input(self.str).output_one_line()
203         return repository.get_blob(sha1)
204
205 class Blob(GitObject):
206     """Represents a git blob object. All the actual data contents of the
207     blob object is stored in the L{data} member, which is a
208     L{BlobData} object."""
209     typename = 'blob'
210     default_perm = '100644'
211     def __init__(self, repository, sha1):
212         self.__repository = repository
213         self.__sha1 = sha1
214     sha1 = property(lambda self: self.__sha1)
215     def __str__(self):
216         return 'Blob<%s>' % self.sha1
217     @property
218     def data(self):
219         return BlobData(self.__repository.cat_object(self.sha1))
220
221 class ImmutableDict(dict):
222     """A dictionary that cannot be modified once it's been created."""
223     def error(*args, **kwargs):
224         raise TypeError('Cannot modify immutable dict')
225     __delitem__ = error
226     __setitem__ = error
227     clear = error
228     pop = error
229     popitem = error
230     setdefault = error
231     update = error
232
233 class TreeData(Immutable, Repr):
234     """Represents the data contents of a git tree object."""
235     @staticmethod
236     def __x(po):
237         if isinstance(po, GitObject):
238             perm, object = po.default_perm, po
239         else:
240             perm, object = po
241         return perm, object
242     def __init__(self, entries):
243         """Create a new L{TreeData} object from the given mapping from names
244         (strings) to either (I{permission}, I{object}) tuples or just
245         objects."""
246         self.__entries = ImmutableDict((name, self.__x(po))
247                                        for (name, po) in entries.iteritems())
248     entries = property(lambda self: self.__entries)
249     """Map from name to (I{permission}, I{object}) tuple."""
250     def set_entry(self, name, po):
251         """Create a new L{TreeData} object identical to this one, except that
252         it maps C{name} to C{po}.
253
254         @param name: Name of the changed mapping
255         @type name: C{str}
256         @param po: Value of the changed mapping
257         @type po: L{Blob} or L{Tree} or (C{str}, L{Blob} or L{Tree})
258         @return: The new L{TreeData} object
259         @rtype: L{TreeData}"""
260         e = dict(self.entries)
261         e[name] = self.__x(po)
262         return type(self)(e)
263     def del_entry(self, name):
264         """Create a new L{TreeData} object identical to this one, except that
265         it doesn't map C{name} to anything.
266
267         @param name: Name of the deleted mapping
268         @type name: C{str}
269         @return: The new L{TreeData} object
270         @rtype: L{TreeData}"""
271         e = dict(self.entries)
272         del e[name]
273         return type(self)(e)
274     def commit(self, repository):
275         """Commit the tree.
276         @return: The committed tree
277         @rtype: L{Tree}"""
278         listing = ''.join(
279             '%s %s %s\t%s\0' % (mode, obj.typename, obj.sha1, name)
280             for (name, (mode, obj)) in self.entries.iteritems())
281         sha1 = repository.run(['git', 'mktree', '-z']
282                               ).raw_input(listing).output_one_line()
283         return repository.get_tree(sha1)
284     @classmethod
285     def parse(cls, repository, s):
286         """Parse a raw git tree description.
287
288         @return: A new L{TreeData} object
289         @rtype: L{TreeData}"""
290         entries = {}
291         for line in s.split('\0')[:-1]:
292             m = re.match(r'^([0-7]{6}) ([a-z]+) ([0-9a-f]{40})\t(.*)$', line)
293             assert m
294             perm, type, sha1, name = m.groups()
295             entries[name] = (perm, repository.get_object(type, sha1))
296         return cls(entries)
297
298 class Tree(GitObject):
299     """Represents a git tree object. All the actual data contents of the
300     tree object is stored in the L{data} member, which is a
301     L{TreeData} object."""
302     typename = 'tree'
303     default_perm = '040000'
304     def __init__(self, repository, sha1):
305         self.__sha1 = sha1
306         self.__repository = repository
307         self.__data = None
308     sha1 = property(lambda self: self.__sha1)
309     @property
310     def data(self):
311         if self.__data == None:
312             self.__data = TreeData.parse(
313                 self.__repository,
314                 self.__repository.run(['git', 'ls-tree', '-z', self.sha1]
315                                       ).raw_output())
316         return self.__data
317     def __str__(self):
318         return 'Tree<sha1: %s>' % self.sha1
319
320 class CommitData(Immutable, Repr):
321     """Represents the data contents of a git commit object."""
322     def __init__(self, tree = NoValue, parents = NoValue, author = NoValue,
323                  committer = NoValue, message = NoValue, defaults = NoValue):
324         d = make_defaults(defaults)
325         self.__tree = d(tree, 'tree')
326         self.__parents = d(parents, 'parents')
327         self.__author = d(author, 'author', Person.author)
328         self.__committer = d(committer, 'committer', Person.committer)
329         self.__message = d(message, 'message')
330     tree = property(lambda self: self.__tree)
331     parents = property(lambda self: self.__parents)
332     @property
333     def parent(self):
334         assert len(self.__parents) == 1
335         return self.__parents[0]
336     author = property(lambda self: self.__author)
337     committer = property(lambda self: self.__committer)
338     message = property(lambda self: self.__message)
339     def set_tree(self, tree):
340         return type(self)(tree = tree, defaults = self)
341     def set_parents(self, parents):
342         return type(self)(parents = parents, defaults = self)
343     def add_parent(self, parent):
344         return type(self)(parents = list(self.parents or []) + [parent],
345                           defaults = self)
346     def set_parent(self, parent):
347         return self.set_parents([parent])
348     def set_author(self, author):
349         return type(self)(author = author, defaults = self)
350     def set_committer(self, committer):
351         return type(self)(committer = committer, defaults = self)
352     def set_message(self, message):
353         return type(self)(message = message, defaults = self)
354     def is_nochange(self):
355         return len(self.parents) == 1 and self.tree == self.parent.data.tree
356     def __str__(self):
357         if self.tree == None:
358             tree = None
359         else:
360             tree = self.tree.sha1
361         if self.parents == None:
362             parents = None
363         else:
364             parents = [p.sha1 for p in self.parents]
365         return ('CommitData<tree: %s, parents: %s, author: %s,'
366                 ' committer: %s, message: "%s">'
367                 ) % (tree, parents, self.author, self.committer, self.message)
368     def commit(self, repository):
369         """Commit the commit.
370         @return: The committed commit
371         @rtype: L{Commit}"""
372         c = ['git', 'commit-tree', self.tree.sha1]
373         for p in self.parents:
374             c.append('-p')
375             c.append(p.sha1)
376         env = {}
377         for p, v1 in ((self.author, 'AUTHOR'),
378                        (self.committer, 'COMMITTER')):
379             if p != None:
380                 for attr, v2 in (('name', 'NAME'), ('email', 'EMAIL'),
381                                  ('date', 'DATE')):
382                     if getattr(p, attr) != None:
383                         env['GIT_%s_%s' % (v1, v2)] = str(getattr(p, attr))
384         sha1 = repository.run(c, env = env).raw_input(self.message
385                                                       ).output_one_line()
386         return repository.get_commit(sha1)
387     @classmethod
388     def parse(cls, repository, s):
389         """Parse a raw git commit description.
390         @return: A new L{CommitData} object
391         @rtype: L{CommitData}"""
392         cd = cls(parents = [])
393         lines = list(s.splitlines(True))
394         for i in xrange(len(lines)):
395             line = lines[i].strip()
396             if not line:
397                 return cd.set_message(''.join(lines[i+1:]))
398             key, value = line.split(None, 1)
399             if key == 'tree':
400                 cd = cd.set_tree(repository.get_tree(value))
401             elif key == 'parent':
402                 cd = cd.add_parent(repository.get_commit(value))
403             elif key == 'author':
404                 cd = cd.set_author(Person.parse(value))
405             elif key == 'committer':
406                 cd = cd.set_committer(Person.parse(value))
407             else:
408                 assert False
409         assert False
410
411 class Commit(GitObject):
412     """Represents a git commit object. All the actual data contents of the
413     commit object is stored in the L{data} member, which is a
414     L{CommitData} object."""
415     typename = 'commit'
416     def __init__(self, repository, sha1):
417         self.__sha1 = sha1
418         self.__repository = repository
419         self.__data = None
420     sha1 = property(lambda self: self.__sha1)
421     @property
422     def data(self):
423         if self.__data == None:
424             self.__data = CommitData.parse(
425                 self.__repository,
426                 self.__repository.cat_object(self.sha1))
427         return self.__data
428     def __str__(self):
429         return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
430
431 class Refs(object):
432     """Accessor for the refs stored in a git repository. Will
433     transparently cache the values of all refs."""
434     def __init__(self, repository):
435         self.__repository = repository
436         self.__refs = None
437     def __cache_refs(self):
438         """(Re-)Build the cache of all refs in the repository."""
439         self.__refs = {}
440         for line in self.__repository.run(['git', 'show-ref']).output_lines():
441             m = re.match(r'^([0-9a-f]{40})\s+(\S+)$', line)
442             sha1, ref = m.groups()
443             self.__refs[ref] = sha1
444     def get(self, ref):
445         """Get the Commit the given ref points to. Throws KeyError if ref
446         doesn't exist."""
447         if self.__refs == None:
448             self.__cache_refs()
449         return self.__repository.get_commit(self.__refs[ref])
450     def exists(self, ref):
451         """Check if the given ref exists."""
452         try:
453             self.get(ref)
454         except KeyError:
455             return False
456         else:
457             return True
458     def set(self, ref, commit, msg):
459         """Write the sha1 of the given Commit to the ref. The ref may or may
460         not already exist."""
461         if self.__refs == None:
462             self.__cache_refs()
463         old_sha1 = self.__refs.get(ref, '0'*40)
464         new_sha1 = commit.sha1
465         if old_sha1 != new_sha1:
466             self.__repository.run(['git', 'update-ref', '-m', msg,
467                                    ref, new_sha1, old_sha1]).no_output()
468             self.__refs[ref] = new_sha1
469     def delete(self, ref):
470         """Delete the given ref. Throws KeyError if ref doesn't exist."""
471         if self.__refs == None:
472             self.__cache_refs()
473         self.__repository.run(['git', 'update-ref',
474                                '-d', ref, self.__refs[ref]]).no_output()
475         del self.__refs[ref]
476
477 class ObjectCache(object):
478     """Cache for Python objects, for making sure that we create only one
479     Python object per git object. This reduces memory consumption and
480     makes object comparison very cheap."""
481     def __init__(self, create):
482         self.__objects = {}
483         self.__create = create
484     def __getitem__(self, name):
485         if not name in self.__objects:
486             self.__objects[name] = self.__create(name)
487         return self.__objects[name]
488     def __contains__(self, name):
489         return name in self.__objects
490     def __setitem__(self, name, val):
491         assert not name in self.__objects
492         self.__objects[name] = val
493
494 class RunWithEnv(object):
495     def run(self, args, env = {}):
496         """Run the given command with an environment given by self.env.
497
498         @type args: list of strings
499         @param args: Command and argument vector
500         @type env: dict
501         @param env: Extra environment"""
502         return run.Run(*args).env(utils.add_dict(self.env, env))
503
504 class RunWithEnvCwd(RunWithEnv):
505     def run(self, args, env = {}):
506         """Run the given command with an environment given by self.env, and
507         current working directory given by self.cwd.
508
509         @type args: list of strings
510         @param args: Command and argument vector
511         @type env: dict
512         @param env: Extra environment"""
513         return RunWithEnv.run(self, args, env).cwd(self.cwd)
514     def run_in_cwd(self, args):
515         """Run the given command with an environment given by self.env and
516         self.env_in_cwd, without changing the current working
517         directory.
518
519         @type args: list of strings
520         @param args: Command and argument vector"""
521         return RunWithEnv.run(self, args, self.env_in_cwd)
522
523 class Repository(RunWithEnv):
524     """Represents a git repository."""
525     def __init__(self, directory):
526         self.__git_dir = directory
527         self.__refs = Refs(self)
528         self.__blobs = ObjectCache(lambda sha1: Blob(self, sha1))
529         self.__trees = ObjectCache(lambda sha1: Tree(self, sha1))
530         self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
531         self.__default_index = None
532         self.__default_worktree = None
533         self.__default_iw = None
534     env = property(lambda self: { 'GIT_DIR': self.__git_dir })
535     @classmethod
536     def default(cls):
537         """Return the default repository."""
538         try:
539             return cls(run.Run('git', 'rev-parse', '--git-dir'
540                                ).output_one_line())
541         except run.RunException:
542             raise RepositoryException('Cannot find git repository')
543     @property
544     def current_branch_name(self):
545         """Return the name of the current branch."""
546         return utils.strip_prefix('refs/heads/', self.head_ref)
547     @property
548     def default_index(self):
549         """An L{Index} object representing the default index file for the
550         repository."""
551         if self.__default_index == None:
552             self.__default_index = Index(
553                 self, (os.environ.get('GIT_INDEX_FILE', None)
554                        or os.path.join(self.__git_dir, 'index')))
555         return self.__default_index
556     def temp_index(self):
557         """Return an L{Index} object representing a new temporary index file
558         for the repository."""
559         return Index(self, self.__git_dir)
560     @property
561     def default_worktree(self):
562         """A L{Worktree} object representing the default work tree."""
563         if self.__default_worktree == None:
564             path = os.environ.get('GIT_WORK_TREE', None)
565             if not path:
566                 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
567                 o = o or ['.']
568                 assert len(o) == 1
569                 path = o[0]
570             self.__default_worktree = Worktree(path)
571         return self.__default_worktree
572     @property
573     def default_iw(self):
574         """An L{IndexAndWorktree} object representing the default index and
575         work tree for this repository."""
576         if self.__default_iw == None:
577             self.__default_iw = IndexAndWorktree(self.default_index,
578                                                  self.default_worktree)
579         return self.__default_iw
580     directory = property(lambda self: self.__git_dir)
581     refs = property(lambda self: self.__refs)
582     def cat_object(self, sha1):
583         return self.run(['git', 'cat-file', '-p', sha1]).raw_output()
584     def rev_parse(self, rev, discard_stderr = False):
585         try:
586             return self.get_commit(self.run(
587                     ['git', 'rev-parse', '%s^{commit}' % rev]
588                     ).discard_stderr(discard_stderr).output_one_line())
589         except run.RunException:
590             raise RepositoryException('%s: No such revision' % rev)
591     def get_blob(self, sha1):
592         return self.__blobs[sha1]
593     def get_tree(self, sha1):
594         return self.__trees[sha1]
595     def get_commit(self, sha1):
596         return self.__commits[sha1]
597     def get_object(self, type, sha1):
598         return { Blob.typename: self.get_blob,
599                  Tree.typename: self.get_tree,
600                  Commit.typename: self.get_commit }[type](sha1)
601     def commit(self, objectdata):
602         return objectdata.commit(self)
603     @property
604     def head_ref(self):
605         try:
606             return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
607                             ).output_one_line()
608         except run.RunException:
609             raise DetachedHeadException()
610     def set_head_ref(self, ref, msg):
611         self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
612     def get_merge_bases(self, commit1, commit2):
613         """Return a set of merge bases of two commits."""
614         sha1_list = self.run(['git', 'merge-base', '--all',
615                               commit1.sha1, commit2.sha1]).output_lines()
616         return set(self.get_commit(sha1) for sha1 in sha1_list)
617     def describe(self, commit):
618         """Use git describe --all on the given commit."""
619         return self.run(['git', 'describe', '--all', commit.sha1]
620                        ).discard_stderr().discard_exitcode().raw_output()
621     def simple_merge(self, base, ours, theirs):
622         index = self.temp_index()
623         try:
624             result, index_tree = index.merge(base, ours, theirs)
625         finally:
626             index.delete()
627         return result
628     def apply(self, tree, patch_text, quiet):
629         """Given a L{Tree} and a patch, will either return the new L{Tree}
630         that results when the patch is applied, or None if the patch
631         couldn't be applied."""
632         assert isinstance(tree, Tree)
633         if not patch_text:
634             return tree
635         index = self.temp_index()
636         try:
637             index.read_tree(tree)
638             try:
639                 index.apply(patch_text, quiet)
640                 return index.write_tree()
641             except MergeException:
642                 return None
643         finally:
644             index.delete()
645     def diff_tree(self, t1, t2, diff_opts, binary = True):
646         """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
647         C{t1} to C{t2}.
648
649         @type diff_opts: list of strings
650         @param diff_opts: Extra diff options
651         @rtype: String
652         @return: Patch text"""
653         assert isinstance(t1, Tree)
654         assert isinstance(t2, Tree)
655         diff_opts = list(diff_opts)
656         if binary and not '--binary' in diff_opts:
657             diff_opts.append('--binary')
658         return self.run(['git', 'diff-tree', '-p'] + diff_opts
659                         + [t1.sha1, t2.sha1]).raw_output()
660     def diff_tree_files(self, t1, t2):
661         """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
662         which they differ. For each file, yield a tuple with the old
663         file mode, the new file mode, the old blob, the new blob, the
664         status, the old filename, and the new filename. Except in case
665         of a copy or a rename, the old and new filenames are
666         identical."""
667         assert isinstance(t1, Tree)
668         assert isinstance(t2, Tree)
669         i = iter(self.run(['git', 'diff-tree', '-r', '-z'] + [t1.sha1, t2.sha1]
670                           ).raw_output().split('\0'))
671         try:
672             while True:
673                 x = i.next()
674                 if not x:
675                     continue
676                 omode, nmode, osha1, nsha1, status = x[1:].split(' ')
677                 fn1 = i.next()
678                 if status[0] in ['C', 'R']:
679                     fn2 = i.next()
680                 else:
681                     fn2 = fn1
682                 yield (omode, nmode, self.get_blob(osha1),
683                        self.get_blob(nsha1), status, fn1, fn2)
684         except StopIteration:
685             pass
686
687 class MergeException(exception.StgException):
688     """Exception raised when a merge fails for some reason."""
689
690 class MergeConflictException(MergeException):
691     """Exception raised when a merge fails due to conflicts."""
692     def __init__(self, conflicts):
693         MergeException.__init__(self)
694         self.conflicts = conflicts
695
696 class Index(RunWithEnv):
697     """Represents a git index file."""
698     def __init__(self, repository, filename):
699         self.__repository = repository
700         if os.path.isdir(filename):
701             # Create a temp index in the given directory.
702             self.__filename = os.path.join(
703                 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
704             self.delete()
705         else:
706             self.__filename = filename
707     env = property(lambda self: utils.add_dict(
708             self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
709     def read_tree(self, tree):
710         self.run(['git', 'read-tree', tree.sha1]).no_output()
711     def write_tree(self):
712         """Write the index contents to the repository.
713         @return: The resulting L{Tree}
714         @rtype: L{Tree}"""
715         try:
716             return self.__repository.get_tree(
717                 self.run(['git', 'write-tree']).discard_stderr(
718                     ).output_one_line())
719         except run.RunException:
720             raise MergeException('Conflicting merge')
721     def is_clean(self, tree):
722         """Check whether the index is clean relative to the given treeish."""
723         try:
724             self.run(['git', 'diff-index', '--quiet', '--cached', tree.sha1]
725                     ).discard_output()
726         except run.RunException:
727             return False
728         else:
729             return True
730     def apply(self, patch_text, quiet):
731         """In-index patch application, no worktree involved."""
732         try:
733             r = self.run(['git', 'apply', '--cached']).raw_input(patch_text)
734             if quiet:
735                 r = r.discard_stderr()
736             r.no_output()
737         except run.RunException:
738             raise MergeException('Patch does not apply cleanly')
739     def apply_treediff(self, tree1, tree2, quiet):
740         """Apply the diff from C{tree1} to C{tree2} to the index."""
741         # Passing --full-index here is necessary to support binary
742         # files. It is also sufficient, since the repository already
743         # contains all involved objects; in other words, we don't have
744         # to use --binary.
745         self.apply(self.__repository.diff_tree(tree1, tree2, ['--full-index']),
746                    quiet)
747     def merge(self, base, ours, theirs, current = None):
748         """Use the index (and only the index) to do a 3-way merge of the
749         L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
750         succeed (in which case the first half of the return value is
751         the resulting tree) or fail cleanly (in which case the first
752         half of the return value is C{None}).
753
754         If C{current} is given (and not C{None}), it is assumed to be
755         the L{Tree} currently stored in the index; this information is
756         used to avoid having to read the right tree (either of C{ours}
757         and C{theirs}) into the index if it's already there. The
758         second half of the return value is the tree now stored in the
759         index, or C{None} if unknown. If the merge succeeded, this is
760         often the merge result."""
761         assert isinstance(base, Tree)
762         assert isinstance(ours, Tree)
763         assert isinstance(theirs, Tree)
764         assert current == None or isinstance(current, Tree)
765
766         # Take care of the really trivial cases.
767         if base == ours:
768             return (theirs, current)
769         if base == theirs:
770             return (ours, current)
771         if ours == theirs:
772             return (ours, current)
773
774         if current == theirs:
775             # Swap the trees. It doesn't matter since merging is
776             # symmetric, and will allow us to avoid the read_tree()
777             # call below.
778             ours, theirs = theirs, ours
779         if current != ours:
780             self.read_tree(ours)
781         try:
782             self.apply_treediff(base, theirs, quiet = True)
783             result = self.write_tree()
784             return (result, result)
785         except MergeException:
786             return (None, ours)
787     def delete(self):
788         if os.path.isfile(self.__filename):
789             os.remove(self.__filename)
790     def conflicts(self):
791         """The set of conflicting paths."""
792         paths = set()
793         for line in self.run(['git', 'ls-files', '-z', '--unmerged']
794                              ).raw_output().split('\0')[:-1]:
795             stat, path = line.split('\t', 1)
796             paths.add(path)
797         return paths
798
799 class Worktree(object):
800     """Represents a git worktree (that is, a checked-out file tree)."""
801     def __init__(self, directory):
802         self.__directory = directory
803     env = property(lambda self: { 'GIT_WORK_TREE': '.' })
804     env_in_cwd = property(lambda self: { 'GIT_WORK_TREE': self.directory })
805     directory = property(lambda self: self.__directory)
806
807 class CheckoutException(exception.StgException):
808     """Exception raised when a checkout fails."""
809
810 class IndexAndWorktree(RunWithEnvCwd):
811     """Represents a git index and a worktree. Anything that an index or
812     worktree can do on their own are handled by the L{Index} and
813     L{Worktree} classes; this class concerns itself with the
814     operations that require both."""
815     def __init__(self, index, worktree):
816         self.__index = index
817         self.__worktree = worktree
818     index = property(lambda self: self.__index)
819     env = property(lambda self: utils.add_dict(self.__index.env,
820                                                self.__worktree.env))
821     env_in_cwd = property(lambda self: self.__worktree.env_in_cwd)
822     cwd = property(lambda self: self.__worktree.directory)
823     def checkout_hard(self, tree):
824         assert isinstance(tree, Tree)
825         self.run(['git', 'read-tree', '--reset', '-u', tree.sha1]
826                  ).discard_output()
827     def checkout(self, old_tree, new_tree):
828         # TODO: Optionally do a 3-way instead of doing nothing when we
829         # have a problem. Or maybe we should stash changes in a patch?
830         assert isinstance(old_tree, Tree)
831         assert isinstance(new_tree, Tree)
832         try:
833             self.run(['git', 'read-tree', '-u', '-m',
834                       '--exclude-per-directory=.gitignore',
835                       old_tree.sha1, new_tree.sha1]
836                      ).discard_output()
837         except run.RunException:
838             raise CheckoutException('Index/workdir dirty')
839     def merge(self, base, ours, theirs, interactive = False):
840         assert isinstance(base, Tree)
841         assert isinstance(ours, Tree)
842         assert isinstance(theirs, Tree)
843         try:
844             r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
845                           theirs.sha1],
846                          env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
847                                  'GITHEAD_%s' % ours.sha1: 'current',
848                                  'GITHEAD_%s' % theirs.sha1: 'patched'})
849             r.returns([0, 1])
850             output = r.output_lines()
851             if r.exitcode:
852                 # There were conflicts
853                 if interactive:
854                     self.mergetool()
855                 else:
856                     conflicts = [l for l in output if l.startswith('CONFLICT')]
857                     raise MergeConflictException(conflicts)
858         except run.RunException, e:
859             raise MergeException('Index/worktree dirty')
860     def mergetool(self, files = ()):
861         """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
862         any outstanding conflicts. If 'not files', all the files in an
863         unmerged state will be processed."""
864         self.run(['git', 'mergetool'] + list(files)).returns([0, 1]).run()
865         # check for unmerged entries (prepend 'CONFLICT ' for consistency with
866         # merge())
867         conflicts = ['CONFLICT ' + f for f in self.index.conflicts()]
868         if conflicts:
869             raise MergeConflictException(conflicts)
870     def changed_files(self, tree, pathlimits = []):
871         """Return the set of files in the worktree that have changed with
872         respect to C{tree}. The listing is optionally restricted to
873         those files that match any of the path limiters given.
874
875         The path limiters are relative to the current working
876         directory; the returned file names are relative to the
877         repository root."""
878         assert isinstance(tree, Tree)
879         return set(self.run_in_cwd(
880                 ['git', 'diff-index', tree.sha1, '--name-only', '-z', '--']
881                 + list(pathlimits)).raw_output().split('\0')[:-1])
882     def update_index(self, paths):
883         """Update the index with files from the worktree. C{paths} is an
884         iterable of paths relative to the root of the repository."""
885         cmd = ['git', 'update-index', '--remove']
886         self.run(cmd + ['-z', '--stdin']
887                  ).input_nulterm(paths).discard_output()
888     def worktree_clean(self):
889         """Check whether the worktree is clean relative to index."""
890         try:
891             self.run(['git', 'update-index', '--refresh']).discard_output()
892         except run.RunException:
893             return False
894         else:
895             return True
896
897 class Branch(object):
898     """Represents a Git branch."""
899     def __init__(self, repository, name):
900         self.__repository = repository
901         self.__name = name
902         try:
903             self.head
904         except KeyError:
905             raise BranchException('%s: no such branch' % name)
906
907     name = property(lambda self: self.__name)
908     repository = property(lambda self: self.__repository)
909
910     def __ref(self):
911         return 'refs/heads/%s' % self.__name
912     @property
913     def head(self):
914         return self.__repository.refs.get(self.__ref())
915     def set_head(self, commit, msg):
916         self.__repository.refs.set(self.__ref(), commit, msg)
917
918     def set_parent_remote(self, name):
919         value = config.set('branch.%s.remote' % self.__name, name)
920     def set_parent_branch(self, name):
921         if config.get('branch.%s.remote' % self.__name):
922             # Never set merge if remote is not set to avoid
923             # possibly-erroneous lookups into 'origin'
924             config.set('branch.%s.merge' % self.__name, name)
925
926     @classmethod
927     def create(cls, repository, name, create_at = None):
928         """Create a new Git branch and return the corresponding
929         L{Branch} object."""
930         try:
931             branch = cls(repository, name)
932         except BranchException:
933             branch = None
934         if branch:
935             raise BranchException('%s: branch already exists' % name)
936
937         cmd = ['git', 'branch']
938         if create_at:
939             cmd.append(create_at.sha1)
940         repository.run(['git', 'branch', create_at.sha1]).discard_output()
941
942         return cls(repository, name)
943
944 def diffstat(diff):
945     """Return the diffstat of the supplied diff."""
946     return run.Run('git', 'apply', '--stat', '--summary'
947                    ).raw_input(diff).raw_output()
948
949 def clone(remote, local):
950     """Clone a remote repository using 'git clone'."""
951     run.Run('git', 'clone', remote, local).run()