bbaba9e94ca37f024965642ebec4625006662cc8
[jelmer/dulwich-libgit2.git] / dulwich / objects.py
1 # objects.py -- Access to base git objects
2 # Copyright (C) 2007 James Westby <jw+debian@jameswestby.net>
3 # Copyright (C) 2008-2009 Jelmer Vernooij <jelmer@samba.org>
4 #
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; version 2
8 # of the License or (at your option) a later version of the License.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
18 # MA  02110-1301, USA.
19
20
21 """Access to base git objects."""
22
23
24 import binascii
25 from cStringIO import (
26     StringIO,
27     )
28 import mmap
29 import os
30 import stat
31 import time
32 import zlib
33
34 from dulwich.errors import (
35     NotBlobError,
36     NotCommitError,
37     NotTreeError,
38     )
39 from dulwich.misc import (
40     make_sha,
41     )
42
43 BLOB_ID = "blob"
44 TAG_ID = "tag"
45 TREE_ID = "tree"
46 COMMIT_ID = "commit"
47 PARENT_ID = "parent"
48 AUTHOR_ID = "author"
49 COMMITTER_ID = "committer"
50 OBJECT_ID = "object"
51 TYPE_ID = "type"
52 TAGGER_ID = "tagger"
53 ENCODING_ID = "encoding"
54
55 S_IFGITLINK     = 0160000
56 def S_ISGITLINK(m):
57     return (stat.S_IFMT(m) == S_IFGITLINK)
58
59 def _decompress(string):
60     dcomp = zlib.decompressobj()
61     dcomped = dcomp.decompress(string)
62     dcomped += dcomp.flush()
63     return dcomped
64
65
66 def sha_to_hex(sha):
67     """Takes a string and returns the hex of the sha within"""
68     hexsha = binascii.hexlify(sha)
69     assert len(hexsha) == 40, "Incorrect length of sha1 string: %d" % hexsha
70     return hexsha
71
72
73 def hex_to_sha(hex):
74     """Takes a hex sha and returns a binary sha"""
75     assert len(hex) == 40, "Incorrent length of hexsha: %s" % hex
76     return binascii.unhexlify(hex)
77
78
79 def serializable_property(name, docstring=None):
80     def set(obj, value):
81         obj._ensure_parsed()
82         setattr(obj, "_"+name, value)
83         obj._needs_serialization = True
84     def get(obj):
85         obj._ensure_parsed()
86         return getattr(obj, "_"+name)
87     return property(get, set, doc=docstring)
88
89
90 class ShaFile(object):
91     """A git SHA file."""
92
93     @classmethod
94     def _parse_legacy_object(cls, map):
95         """Parse a legacy object, creating it and setting object._text"""
96         text = _decompress(map)
97         object = None
98         for posstype in type_map.keys():
99             if text.startswith(posstype):
100                 object = type_map[posstype]()
101                 text = text[len(posstype):]
102                 break
103         assert object is not None, "%s is not a known object type" % text[:9]
104         assert text[0] == ' ', "%s is not a space" % text[0]
105         text = text[1:]
106         size = 0
107         i = 0
108         while text[0] >= '0' and text[0] <= '9':
109             if i > 0 and size == 0:
110                 raise AssertionError("Size is not in canonical format")
111             size = (size * 10) + int(text[0])
112             text = text[1:]
113             i += 1
114         object._size = size
115         assert text[0] == "\0", "Size not followed by null"
116         text = text[1:]
117         object.set_raw_string(text)
118         return object
119
120     def as_legacy_object(self):
121         text = self.as_raw_string()
122         return zlib.compress("%s %d\0%s" % (self._type, len(text), text))
123
124     def as_raw_string(self):
125         if self._needs_serialization:
126             self.serialize()
127         return self._text
128
129     def __str__(self):
130         return self.as_raw_string()
131
132     def __hash__(self):
133         return hash(self.id)
134
135     def as_pretty_string(self):
136         return self.as_raw_string()
137
138     def _ensure_parsed(self):
139         if self._needs_parsing:
140             self._parse_text()
141
142     def set_raw_string(self, text):
143         if type(text) != str:
144             raise TypeError(text)
145         self._text = text
146         self._sha = None
147         self._needs_parsing = True
148         self._needs_serialization = False
149
150     @classmethod
151     def _parse_object(cls, map):
152         """Parse a new style object , creating it and setting object._text"""
153         used = 0
154         byte = ord(map[used])
155         used += 1
156         num_type = (byte >> 4) & 7
157         try:
158             object = num_type_map[num_type]()
159         except KeyError:
160             raise AssertionError("Not a known type: %d" % num_type)
161         while (byte & 0x80) != 0:
162             byte = ord(map[used])
163             used += 1
164         raw = map[used:]
165         object.set_raw_string(_decompress(raw))
166         return object
167
168     @classmethod
169     def _parse_file(cls, map):
170         word = (ord(map[0]) << 8) + ord(map[1])
171         if ord(map[0]) == 0x78 and (word % 31) == 0:
172             return cls._parse_legacy_object(map)
173         else:
174             return cls._parse_object(map)
175
176     def __init__(self):
177         """Don't call this directly"""
178         self._sha = None
179
180     def _parse_text(self):
181         """For subclasses to do initialisation time parsing"""
182
183     @classmethod
184     def from_file(cls, filename):
185         """Get the contents of a SHA file on disk"""
186         size = os.path.getsize(filename)
187         f = open(filename, 'rb')
188         try:
189             map = mmap.mmap(f.fileno(), size, access=mmap.ACCESS_READ)
190             shafile = cls._parse_file(map)
191             return shafile
192         finally:
193             f.close()
194
195     @classmethod
196     def from_raw_string(cls, type, string):
197         """Creates an object of the indicated type from the raw string given.
198
199         Type is the numeric type of an object. String is the raw uncompressed
200         contents.
201         """
202         real_class = num_type_map[type]
203         obj = real_class()
204         obj.type = type
205         obj.set_raw_string(string)
206         return obj
207
208     @classmethod
209     def from_string(cls, string):
210         """Create a blob from a string."""
211         shafile = cls()
212         shafile.set_raw_string(string)
213         return shafile
214
215     def _header(self):
216         return "%s %lu\0" % (self._type, len(self.as_raw_string()))
217
218     def sha(self):
219         """The SHA1 object that is the name of this object."""
220         if self._needs_serialization or self._sha is None:
221             self._sha = make_sha()
222             self._sha.update(self._header())
223             self._sha.update(self.as_raw_string())
224         return self._sha
225
226     @property
227     def id(self):
228         return self.sha().hexdigest()
229
230     def get_type(self):
231         return self._num_type
232
233     def set_type(self, type):
234         self._num_type = type
235
236     type = property(get_type, set_type)
237
238     def __repr__(self):
239         return "<%s %s>" % (self.__class__.__name__, self.id)
240
241     def __ne__(self, other):
242         return self.id != other.id
243
244     def __eq__(self, other):
245         """Return true id the sha of the two objects match.
246
247         The __le__ etc methods aren't overriden as they make no sense,
248         certainly at this level.
249         """
250         return self.id == other.id
251
252
253 class Blob(ShaFile):
254     """A Git Blob object."""
255
256     _type = BLOB_ID
257     _num_type = 3
258     _needs_serialization = False
259     _needs_parsing = False
260
261     def get_data(self):
262         return self._text
263
264     def set_data(self, data):
265         self._text = data
266
267     data = property(get_data, set_data,
268             "The text contained within the blob object.")
269
270     @classmethod
271     def from_file(cls, filename):
272         blob = ShaFile.from_file(filename)
273         if blob._type != cls._type:
274             raise NotBlobError(filename)
275         return blob
276
277
278 class Tag(ShaFile):
279     """A Git Tag object."""
280
281     _type = TAG_ID
282     _num_type = 4
283
284     def __init__(self):
285         super(Tag, self).__init__()
286         self._needs_parsing = False
287         self._needs_serialization = True
288
289     @classmethod
290     def from_file(cls, filename):
291         blob = ShaFile.from_file(filename)
292         if blob._type != cls._type:
293             raise NotBlobError(filename)
294         return blob
295
296     @classmethod
297     def from_string(cls, string):
298         """Create a blob from a string."""
299         shafile = cls()
300         shafile.set_raw_string(string)
301         return shafile
302
303     def serialize(self):
304         f = StringIO()
305         f.write("%s %s\n" % (OBJECT_ID, self._object_sha))
306         f.write("%s %s\n" % (TYPE_ID, num_type_map[self._object_type]._type))
307         f.write("%s %s\n" % (TAG_ID, self._name))
308         if self._tagger:
309             if self._tag_time is None:
310                 f.write("%s %s\n" % (TAGGER_ID, self._tagger))
311             else:
312                 f.write("%s %s %d %s\n" % (TAGGER_ID, self._tagger, self._tag_time, format_timezone(self._tag_timezone)))
313         f.write("\n") # To close headers
314         f.write(self._message)
315         self._text = f.getvalue()
316         self._needs_serialization = False
317
318     def _parse_text(self):
319         """Grab the metadata attached to the tag"""
320         self._tagger = None
321         f = StringIO(self._text)
322         for l in f:
323             l = l.rstrip("\n")
324             if l == "":
325                 break # empty line indicates end of headers
326             (field, value) = l.split(" ", 1)
327             if field == OBJECT_ID:
328                 self._object_sha = value
329             elif field == TYPE_ID:
330                 self._object_type = type_map[value]
331             elif field == TAG_ID:
332                 self._name = value
333             elif field == TAGGER_ID:
334                 try:
335                     sep = value.index("> ")
336                 except ValueError:
337                     self._tagger = value
338                     self._tag_time = None
339                     self._tag_timezone = None
340                 else:
341                     self._tagger = value[0:sep+1]
342                     (timetext, timezonetext) = value[sep+2:].rsplit(" ", 1)
343                     try:
344                         self._tag_time = int(timetext)
345                     except ValueError: #Not a unix timestamp
346                         self._tag_time = time.strptime(timetext)
347                     self._tag_timezone = parse_timezone(timezonetext)
348             else:
349                 raise AssertionError("Unknown field %s" % field)
350         self._message = f.read()
351         self._needs_parsing = False
352
353     def get_object(self):
354         """Returns the object pointed by this tag, represented as a tuple(type, sha)"""
355         self._ensure_parsed()
356         return (self._object_type, self._object_sha)
357
358     def set_object(self, value):
359         self._ensure_parsed()
360         (self._object_type, self._object_sha) = value
361         self._needs_serialization = True
362
363     object = property(get_object, set_object)
364
365     name = serializable_property("name", "The name of this tag")
366     tagger = serializable_property("tagger",
367         "Returns the name of the person who created this tag")
368     tag_time = serializable_property("tag_time",
369         "The creation timestamp of the tag.  As the number of seconds since the epoch")
370     tag_timezone = serializable_property("tag_timezone",
371         "The timezone that tag_time is in.")
372     message = serializable_property("message", "The message attached to this tag")
373
374
375 def parse_tree(text):
376     ret = {}
377     count = 0
378     l = len(text)
379     while count < l:
380         mode_end = text.index(' ', count)
381         mode = int(text[count:mode_end], 8)
382
383         name_end = text.index('\0', mode_end)
384         name = text[mode_end+1:name_end]
385
386         count = name_end+21
387
388         sha = text[name_end+1:count]
389
390         ret[name] = (mode, sha_to_hex(sha))
391
392     return ret
393
394
395 class Tree(ShaFile):
396     """A Git tree object"""
397
398     _type = TREE_ID
399     _num_type = 2
400
401     def __init__(self):
402         super(Tree, self).__init__()
403         self._entries = {}
404         self._needs_parsing = False
405         self._needs_serialization = True
406
407     @classmethod
408     def from_file(cls, filename):
409         tree = ShaFile.from_file(filename)
410         if tree._type != cls._type:
411             raise NotTreeError(filename)
412         return tree
413
414     def __contains__(self, name):
415         self._ensure_parsed()
416         return name in self._entries
417
418     def __getitem__(self, name):
419         self._ensure_parsed()
420         return self._entries[name]
421
422     def __setitem__(self, name, value):
423         assert isinstance(value, tuple)
424         assert len(value) == 2
425         self._ensure_parsed()
426         self._entries[name] = value
427         self._needs_serialization = True
428
429     def __delitem__(self, name):
430         self._ensure_parsed()
431         del self._entries[name]
432         self._needs_serialization = True
433
434     def __len__(self):
435         self._ensure_parsed()
436         return len(self._entries)
437
438     def add(self, mode, name, hexsha):
439         assert type(mode) == int
440         assert type(name) == str
441         assert type(hexsha) == str
442         self._ensure_parsed()
443         self._entries[name] = mode, hexsha
444         self._needs_serialization = True
445
446     def entries(self):
447         """Return a list of tuples describing the tree entries"""
448         self._ensure_parsed()
449         # The order of this is different from iteritems() for historical reasons
450         return [(mode, name, hexsha) for (name, mode, hexsha) in self.iteritems()]
451
452     def iteritems(self):
453         def cmp_entry((name1, value1), (name2, value2)):
454             if stat.S_ISDIR(value1[0]):
455                 name1 += "/"
456             if stat.S_ISDIR(value2[0]):
457                 name2 += "/"
458             return cmp(name1, name2)
459         self._ensure_parsed()
460         for name, entry in sorted(self._entries.iteritems(), cmp=cmp_entry):
461             yield name, entry[0], entry[1]
462
463     def _parse_text(self):
464         """Grab the entries in the tree"""
465         self._entries = parse_tree(self._text)
466         self._needs_parsing = False
467
468     def serialize(self):
469         f = StringIO()
470         for name, mode, hexsha in self.iteritems():
471             f.write("%04o %s\0%s" % (mode, name, hex_to_sha(hexsha)))
472         self._text = f.getvalue()
473         self._needs_serialization = False
474
475     def as_pretty_string(self):
476         text = ""
477         for name, mode, hexsha in self.iteritems():
478             if mode & stat.S_IFDIR:
479                 kind = "tree"
480             else:
481                 kind = "blob"
482             text += "%04o %s %s\t%s\n" % (mode, kind, hexsha, name)
483         return text
484
485
486 def parse_timezone(text):
487     offset = int(text)
488     signum = (offset < 0) and -1 or 1
489     offset = abs(offset)
490     hours = int(offset / 100)
491     minutes = (offset % 100)
492     return signum * (hours * 3600 + minutes * 60)
493
494
495 def format_timezone(offset):
496     if offset % 60 != 0:
497         raise ValueError("Unable to handle non-minute offset.")
498     sign = (offset < 0) and '-' or '+'
499     offset = abs(offset)
500     return '%c%02d%02d' % (sign, offset / 3600, (offset / 60) % 60)
501
502
503 class Commit(ShaFile):
504     """A git commit object"""
505
506     _type = COMMIT_ID
507     _num_type = 1
508
509     def __init__(self):
510         super(Commit, self).__init__()
511         self._parents = []
512         self._encoding = None
513         self._needs_parsing = False
514         self._needs_serialization = True
515
516     @classmethod
517     def from_file(cls, filename):
518         commit = ShaFile.from_file(filename)
519         if commit._type != cls._type:
520             raise NotCommitError(filename)
521         return commit
522
523     def _parse_text(self):
524         self._parents = []
525         self._author = None
526         f = StringIO(self._text)
527         for l in f:
528             l = l.rstrip("\n")
529             if l == "":
530                 # Empty line indicates end of headers
531                 break
532             (field, value) = l.split(" ", 1)
533             if field == TREE_ID:
534                 self._tree = value
535             elif field == PARENT_ID:
536                 self._parents.append(value)
537             elif field == AUTHOR_ID:
538                 self._author, timetext, timezonetext = value.rsplit(" ", 2)
539                 self._author_time = int(timetext)
540                 self._author_timezone = parse_timezone(timezonetext)
541             elif field == COMMITTER_ID:
542                 self._committer, timetext, timezonetext = value.rsplit(" ", 2)
543                 self._commit_time = int(timetext)
544                 self._commit_timezone = parse_timezone(timezonetext)
545             elif field == ENCODING_ID:
546                 self._encoding = value
547             else:
548                 raise AssertionError("Unknown field %s" % field)
549         self._message = f.read()
550         self._needs_parsing = False
551
552     def serialize(self):
553         f = StringIO()
554         f.write("%s %s\n" % (TREE_ID, self._tree))
555         for p in self._parents:
556             f.write("%s %s\n" % (PARENT_ID, p))
557         f.write("%s %s %s %s\n" % (AUTHOR_ID, self._author, str(self._author_time), format_timezone(self._author_timezone)))
558         f.write("%s %s %s %s\n" % (COMMITTER_ID, self._committer, str(self._commit_time), format_timezone(self._commit_timezone)))
559         if self.encoding:
560             f.write("%s %s\n" % (ENCODING_ID, self.encoding))
561         f.write("\n") # There must be a new line after the headers
562         f.write(self._message)
563         self._text = f.getvalue()
564         self._needs_serialization = False
565
566     tree = serializable_property("tree", "Tree that is the state of this commit")
567
568     def get_parents(self):
569         """Return a list of parents of this commit."""
570         self._ensure_parsed()
571         return self._parents
572
573     def set_parents(self, value):
574         """Return a list of parents of this commit."""
575         self._ensure_parsed()
576         self._needs_serialization = True
577         self._parents = value
578
579     parents = property(get_parents, set_parents)
580
581     author = serializable_property("author",
582         "The name of the author of the commit")
583
584     committer = serializable_property("committer",
585         "The name of the committer of the commit")
586
587     message = serializable_property("message",
588         "The commit message")
589
590     commit_time = serializable_property("commit_time",
591         "The timestamp of the commit. As the number of seconds since the epoch.")
592
593     commit_timezone = serializable_property("commit_timezone",
594         "The zone the commit time is in")
595
596     author_time = serializable_property("author_time",
597         "The timestamp the commit was written. as the number of seconds since the epoch.")
598
599     author_timezone = serializable_property("author_timezone",
600         "Returns the zone the author time is in.")
601
602     encoding = serializable_property("encoding",
603         "Encoding of the commit message.")
604
605
606 type_map = {
607     BLOB_ID : Blob,
608     TREE_ID : Tree,
609     COMMIT_ID : Commit,
610     TAG_ID: Tag,
611 }
612
613 num_type_map = {
614     0: None,
615     1: Commit,
616     2: Tree,
617     3: Blob,
618     4: Tag,
619     # 5 Is reserved for further expansion
620 }
621
622 try:
623     # Try to import C versions
624     from dulwich._objects import parse_tree
625 except ImportError:
626     pass
627