| 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() |