3378728378c12ff06bcc212f74cbb9b4485bb30f
[stgit] / stgit / lib / git.py
1 """A Python class hierarchy wrapping a git repository and its
2 contents."""
3
4 import atexit, os, os.path, re, signal
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 CatFileProcess(object):
524 def __init__(self, repo):
525 self.__repo = repo
526 self.__proc = None
527 atexit.register(self.__shutdown)
528 def __get_process(self):
529 if not self.__proc:
530 self.__proc = self.__repo.run(['git', 'cat-file', '--batch']
531 ).run_background()
532 return self.__proc
533 def __shutdown(self):
534 p = self.__proc
535 if p:
536 os.kill(p.pid(), signal.SIGTERM)
537 p.wait()
538 def cat_file(self, sha1):
539 p = self.__get_process()
540 p.stdin.write('%s\n' % sha1)
541 p.stdin.flush()
542
543 # Read until we have the entire status line.
544 s = ''
545 while not '\n' in s:
546 s += os.read(p.stdout.fileno(), 4096)
547 h, b = s.split('\n', 1)
548 if h == '%s missing' % sha1:
549 raise SomeException()
550 hash, type, length = h.split()
551 assert hash == sha1
552 length = int(length)
553
554 # Read until we have the entire object plus the trailing
555 # newline.
556 while len(b) < length + 1:
557 b += os.read(p.stdout.fileno(), 4096)
558 return type, b[:-1]
559
560 class Repository(RunWithEnv):
561 """Represents a git repository."""
562 def __init__(self, directory):
563 self.__git_dir = directory
564 self.__refs = Refs(self)
565 self.__blobs = ObjectCache(lambda sha1: Blob(self, sha1))
566 self.__trees = ObjectCache(lambda sha1: Tree(self, sha1))
567 self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
568 self.__default_index = None
569 self.__default_worktree = None
570 self.__default_iw = None
571 self.__catfile = CatFileProcess(self)
572 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
573 @classmethod
574 def default(cls):
575 """Return the default repository."""
576 try:
577 return cls(run.Run('git', 'rev-parse', '--git-dir'
578 ).output_one_line())
579 except run.RunException:
580 raise RepositoryException('Cannot find git repository')
581 @property
582 def current_branch_name(self):
583 """Return the name of the current branch."""
584 return utils.strip_prefix('refs/heads/', self.head_ref)
585 @property
586 def default_index(self):
587 """An L{Index} object representing the default index file for the
588 repository."""
589 if self.__default_index == None:
590 self.__default_index = Index(
591 self, (os.environ.get('GIT_INDEX_FILE', None)
592 or os.path.join(self.__git_dir, 'index')))
593 return self.__default_index
594 def temp_index(self):
595 """Return an L{Index} object representing a new temporary index file
596 for the repository."""
597 return Index(self, self.__git_dir)
598 @property
599 def default_worktree(self):
600 """A L{Worktree} object representing the default work tree."""
601 if self.__default_worktree == None:
602 path = os.environ.get('GIT_WORK_TREE', None)
603 if not path:
604 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
605 o = o or ['.']
606 assert len(o) == 1
607 path = o[0]
608 self.__default_worktree = Worktree(path)
609 return self.__default_worktree
610 @property
611 def default_iw(self):
612 """An L{IndexAndWorktree} object representing the default index and
613 work tree for this repository."""
614 if self.__default_iw == None:
615 self.__default_iw = IndexAndWorktree(self.default_index,
616 self.default_worktree)
617 return self.__default_iw
618 directory = property(lambda self: self.__git_dir)
619 refs = property(lambda self: self.__refs)
620 def cat_object(self, sha1):
621 return self.__catfile.cat_file(sha1)[1]
622 def rev_parse(self, rev, discard_stderr = False, object_type = 'commit'):
623 assert object_type in ('commit', 'tree', 'blob')
624 getter = getattr(self, 'get_' + object_type)
625 try:
626 return getter(self.run(
627 ['git', 'rev-parse', '%s^{%s}' % (rev, object_type)]
628 ).discard_stderr(discard_stderr).output_one_line())
629 except run.RunException:
630 raise RepositoryException('%s: No such %s' % (rev, object_type))
631 def get_blob(self, sha1):
632 return self.__blobs[sha1]
633 def get_tree(self, sha1):
634 return self.__trees[sha1]
635 def get_commit(self, sha1):
636 return self.__commits[sha1]
637 def get_object(self, type, sha1):
638 return { Blob.typename: self.get_blob,
639 Tree.typename: self.get_tree,
640 Commit.typename: self.get_commit }[type](sha1)
641 def commit(self, objectdata):
642 return objectdata.commit(self)
643 @property
644 def head_ref(self):
645 try:
646 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
647 ).output_one_line()
648 except run.RunException:
649 raise DetachedHeadException()
650 def set_head_ref(self, ref, msg):
651 self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
652 def get_merge_bases(self, commit1, commit2):
653 """Return a set of merge bases of two commits."""
654 sha1_list = self.run(['git', 'merge-base', '--all',
655 commit1.sha1, commit2.sha1]).output_lines()
656 return [self.get_commit(sha1) for sha1 in sha1_list]
657 def describe(self, commit):
658 """Use git describe --all on the given commit."""
659 return self.run(['git', 'describe', '--all', commit.sha1]
660 ).discard_stderr().discard_exitcode().raw_output()
661 def simple_merge(self, base, ours, theirs):
662 index = self.temp_index()
663 try:
664 result, index_tree = index.merge(base, ours, theirs)
665 finally:
666 index.delete()
667 return result
668 def apply(self, tree, patch_text, quiet):
669 """Given a L{Tree} and a patch, will either return the new L{Tree}
670 that results when the patch is applied, or None if the patch
671 couldn't be applied."""
672 assert isinstance(tree, Tree)
673 if not patch_text:
674 return tree
675 index = self.temp_index()
676 try:
677 index.read_tree(tree)
678 try:
679 index.apply(patch_text, quiet)
680 return index.write_tree()
681 except MergeException:
682 return None
683 finally:
684 index.delete()
685 def diff_tree(self, t1, t2, diff_opts, binary = True):
686 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
687 C{t1} to C{t2}.
688
689 @type diff_opts: list of strings
690 @param diff_opts: Extra diff options
691 @rtype: String
692 @return: Patch text"""
693 assert isinstance(t1, Tree)
694 assert isinstance(t2, Tree)
695 diff_opts = list(diff_opts)
696 if binary and not '--binary' in diff_opts:
697 diff_opts.append('--binary')
698 return self.run(['git', 'diff-tree', '-p'] + diff_opts
699 + [t1.sha1, t2.sha1]).raw_output()
700 def diff_tree_files(self, t1, t2):
701 """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
702 which they differ. For each file, yield a tuple with the old
703 file mode, the new file mode, the old blob, the new blob, the
704 status, the old filename, and the new filename. Except in case
705 of a copy or a rename, the old and new filenames are
706 identical."""
707 assert isinstance(t1, Tree)
708 assert isinstance(t2, Tree)
709 i = iter(self.run(['git', 'diff-tree', '-r', '-z'] + [t1.sha1, t2.sha1]
710 ).raw_output().split('\0'))
711 try:
712 while True:
713 x = i.next()
714 if not x:
715 continue
716 omode, nmode, osha1, nsha1, status = x[1:].split(' ')
717 fn1 = i.next()
718 if status[0] in ['C', 'R']:
719 fn2 = i.next()
720 else:
721 fn2 = fn1
722 yield (omode, nmode, self.get_blob(osha1),
723 self.get_blob(nsha1), status, fn1, fn2)
724 except StopIteration:
725 pass
726
727 class MergeException(exception.StgException):
728 """Exception raised when a merge fails for some reason."""
729
730 class MergeConflictException(MergeException):
731 """Exception raised when a merge fails due to conflicts."""
732 def __init__(self, conflicts):
733 MergeException.__init__(self)
734 self.conflicts = conflicts
735
736 class Index(RunWithEnv):
737 """Represents a git index file."""
738 def __init__(self, repository, filename):
739 self.__repository = repository
740 if os.path.isdir(filename):
741 # Create a temp index in the given directory.
742 self.__filename = os.path.join(
743 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
744 self.delete()
745 else:
746 self.__filename = filename
747 env = property(lambda self: utils.add_dict(
748 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
749 def read_tree(self, tree):
750 self.run(['git', 'read-tree', tree.sha1]).no_output()
751 def write_tree(self):
752 """Write the index contents to the repository.
753 @return: The resulting L{Tree}
754 @rtype: L{Tree}"""
755 try:
756 return self.__repository.get_tree(
757 self.run(['git', 'write-tree']).discard_stderr(
758 ).output_one_line())
759 except run.RunException:
760 raise MergeException('Conflicting merge')
761 def is_clean(self, tree):
762 """Check whether the index is clean relative to the given treeish."""
763 try:
764 self.run(['git', 'diff-index', '--quiet', '--cached', tree.sha1]
765 ).discard_output()
766 except run.RunException:
767 return False
768 else:
769 return True
770 def apply(self, patch_text, quiet):
771 """In-index patch application, no worktree involved."""
772 try:
773 r = self.run(['git', 'apply', '--cached']).raw_input(patch_text)
774 if quiet:
775 r = r.discard_stderr()
776 r.no_output()
777 except run.RunException:
778 raise MergeException('Patch does not apply cleanly')
779 def apply_treediff(self, tree1, tree2, quiet):
780 """Apply the diff from C{tree1} to C{tree2} to the index."""
781 # Passing --full-index here is necessary to support binary
782 # files. It is also sufficient, since the repository already
783 # contains all involved objects; in other words, we don't have
784 # to use --binary.
785 self.apply(self.__repository.diff_tree(tree1, tree2, ['--full-index']),
786 quiet)
787 def merge(self, base, ours, theirs, current = None):
788 """Use the index (and only the index) to do a 3-way merge of the
789 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
790 succeed (in which case the first half of the return value is
791 the resulting tree) or fail cleanly (in which case the first
792 half of the return value is C{None}).
793
794 If C{current} is given (and not C{None}), it is assumed to be
795 the L{Tree} currently stored in the index; this information is
796 used to avoid having to read the right tree (either of C{ours}
797 and C{theirs}) into the index if it's already there. The
798 second half of the return value is the tree now stored in the
799 index, or C{None} if unknown. If the merge succeeded, this is
800 often the merge result."""
801 assert isinstance(base, Tree)
802 assert isinstance(ours, Tree)
803 assert isinstance(theirs, Tree)
804 assert current == None or isinstance(current, Tree)
805
806 # Take care of the really trivial cases.
807 if base == ours:
808 return (theirs, current)
809 if base == theirs:
810 return (ours, current)
811 if ours == theirs:
812 return (ours, current)
813
814 if current == theirs:
815 # Swap the trees. It doesn't matter since merging is
816 # symmetric, and will allow us to avoid the read_tree()
817 # call below.
818 ours, theirs = theirs, ours
819 if current != ours:
820 self.read_tree(ours)
821 try:
822 self.apply_treediff(base, theirs, quiet = True)
823 result = self.write_tree()
824 return (result, result)
825 except MergeException:
826 return (None, ours)
827 def delete(self):
828 if os.path.isfile(self.__filename):
829 os.remove(self.__filename)
830 def conflicts(self):
831 """The set of conflicting paths."""
832 paths = set()
833 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
834 ).raw_output().split('\0')[:-1]:
835 stat, path = line.split('\t', 1)
836 paths.add(path)
837 return paths
838
839 class Worktree(object):
840 """Represents a git worktree (that is, a checked-out file tree)."""
841 def __init__(self, directory):
842 self.__directory = directory
843 env = property(lambda self: { 'GIT_WORK_TREE': '.' })
844 env_in_cwd = property(lambda self: { 'GIT_WORK_TREE': self.directory })
845 directory = property(lambda self: self.__directory)
846
847 class CheckoutException(exception.StgException):
848 """Exception raised when a checkout fails."""
849
850 class IndexAndWorktree(RunWithEnvCwd):
851 """Represents a git index and a worktree. Anything that an index or
852 worktree can do on their own are handled by the L{Index} and
853 L{Worktree} classes; this class concerns itself with the
854 operations that require both."""
855 def __init__(self, index, worktree):
856 self.__index = index
857 self.__worktree = worktree
858 index = property(lambda self: self.__index)
859 env = property(lambda self: utils.add_dict(self.__index.env,
860 self.__worktree.env))
861 env_in_cwd = property(lambda self: self.__worktree.env_in_cwd)
862 cwd = property(lambda self: self.__worktree.directory)
863 def checkout_hard(self, tree):
864 assert isinstance(tree, Tree)
865 self.run(['git', 'read-tree', '--reset', '-u', tree.sha1]
866 ).discard_output()
867 def checkout(self, old_tree, new_tree):
868 # TODO: Optionally do a 3-way instead of doing nothing when we
869 # have a problem. Or maybe we should stash changes in a patch?
870 assert isinstance(old_tree, Tree)
871 assert isinstance(new_tree, Tree)
872 try:
873 self.run(['git', 'read-tree', '-u', '-m',
874 '--exclude-per-directory=.gitignore',
875 old_tree.sha1, new_tree.sha1]
876 ).discard_output()
877 except run.RunException:
878 raise CheckoutException('Index/workdir dirty')
879 def merge(self, base, ours, theirs, interactive = False):
880 assert isinstance(base, Tree)
881 assert isinstance(ours, Tree)
882 assert isinstance(theirs, Tree)
883 try:
884 r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
885 theirs.sha1],
886 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
887 'GITHEAD_%s' % ours.sha1: 'current',
888 'GITHEAD_%s' % theirs.sha1: 'patched'})
889 r.returns([0, 1])
890 output = r.output_lines()
891 if r.exitcode:
892 # There were conflicts
893 if interactive:
894 self.mergetool()
895 else:
896 conflicts = [l for l in output if l.startswith('CONFLICT')]
897 raise MergeConflictException(conflicts)
898 except run.RunException, e:
899 raise MergeException('Index/worktree dirty')
900 def mergetool(self, files = ()):
901 """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
902 any outstanding conflicts. If 'not files', all the files in an
903 unmerged state will be processed."""
904 self.run(['git', 'mergetool'] + list(files)).returns([0, 1]).run()
905 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
906 # merge())
907 conflicts = ['CONFLICT ' + f for f in self.index.conflicts()]
908 if conflicts:
909 raise MergeConflictException(conflicts)
910 def changed_files(self, tree, pathlimits = []):
911 """Return the set of files in the worktree that have changed with
912 respect to C{tree}. The listing is optionally restricted to
913 those files that match any of the path limiters given.
914
915 The path limiters are relative to the current working
916 directory; the returned file names are relative to the
917 repository root."""
918 assert isinstance(tree, Tree)
919 return set(self.run_in_cwd(
920 ['git', 'diff-index', tree.sha1, '--name-only', '-z', '--']
921 + list(pathlimits)).raw_output().split('\0')[:-1])
922 def update_index(self, paths):
923 """Update the index with files from the worktree. C{paths} is an
924 iterable of paths relative to the root of the repository."""
925 cmd = ['git', 'update-index', '--remove']
926 self.run(cmd + ['-z', '--stdin']
927 ).input_nulterm(paths).discard_output()
928 def worktree_clean(self):
929 """Check whether the worktree is clean relative to index."""
930 try:
931 self.run(['git', 'update-index', '--refresh']).discard_output()
932 except run.RunException:
933 return False
934 else:
935 return True
936
937 class Branch(object):
938 """Represents a Git branch."""
939 def __init__(self, repository, name):
940 self.__repository = repository
941 self.__name = name
942 try:
943 self.head
944 except KeyError:
945 raise BranchException('%s: no such branch' % name)
946
947 name = property(lambda self: self.__name)
948 repository = property(lambda self: self.__repository)
949
950 def __ref(self):
951 return 'refs/heads/%s' % self.__name
952 @property
953 def head(self):
954 return self.__repository.refs.get(self.__ref())
955 def set_head(self, commit, msg):
956 self.__repository.refs.set(self.__ref(), commit, msg)
957
958 def set_parent_remote(self, name):
959 value = config.set('branch.%s.remote' % self.__name, name)
960 def set_parent_branch(self, name):
961 if config.get('branch.%s.remote' % self.__name):
962 # Never set merge if remote is not set to avoid
963 # possibly-erroneous lookups into 'origin'
964 config.set('branch.%s.merge' % self.__name, name)
965
966 @classmethod
967 def create(cls, repository, name, create_at = None):
968 """Create a new Git branch and return the corresponding
969 L{Branch} object."""
970 try:
971 branch = cls(repository, name)
972 except BranchException:
973 branch = None
974 if branch:
975 raise BranchException('%s: branch already exists' % name)
976
977 cmd = ['git', 'branch']
978 if create_at:
979 cmd.append(create_at.sha1)
980 repository.run(['git', 'branch', create_at.sha1]).discard_output()
981
982 return cls(repository, name)
983
984 def diffstat(diff):
985 """Return the diffstat of the supplied diff."""
986 return run.Run('git', 'apply', '--stat', '--summary'
987 ).raw_input(diff).raw_output()
988
989 def clone(remote, local):
990 """Clone a remote repository using 'git clone'."""
991 run.Run('git', 'clone', remote, local).run()