1 """A Python class hierarchy wrapping a git repository and its
5 from datetime import datetime, timedelta, tzinfo
7 from stgit import exception, run, utils
8 from stgit.config import config
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.
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.)
23 The L{Immutable} class doesn't actually enforce immutability --
24 that is up to the individual immutable subclasses. It just serves
27 class RepositoryException(exception.StgException):
28 """Base class for all exceptions due to failed L{Repository}
31 class BranchException(exception.StgException):
32 """Exception raised by failed L{Branch} operations."""
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))
40 class DetachedHeadException(RepositoryException):
41 """Exception raised when HEAD is detached (that is, there is no
44 RepositoryException.__init__(self, 'Not on any branch')
47 """Utility class that defines C{__reps__} in terms of C{__str__}."""
51 class NoValue(object):
52 """A handy default value that is guaranteed to be distinct from any
53 real argument value."""
56 def make_defaults(defaults):
57 def d(val, attr, default_fun = lambda: None):
60 elif defaults != NoValue:
61 return getattr(defaults, attr)
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)
73 raise DateException(tzstring, 'time zone')
74 sign = int(m.group(1) + '1')
76 self.__offset = timedelta(hours = sign*int(m.group(2)),
77 minutes = sign*int(m.group(3)))
79 raise DateException(tzstring, 'time zone')
80 self.__name = tzstring
81 def utcoffset(self, dt):
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)
97 self.__time = datetime.fromtimestamp(int(m.group(1)),
100 raise DateException(datestring, 'date')
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)
108 self.__time = datetime(
109 *[int(m.group(i + 1)) for i in xrange(6)],
110 **{'tzinfo': TimeZone(m.group(7))})
112 raise DateException(datestring, 'date')
115 raise DateException(datestring, 'date')
117 return self.isoformat()
119 """Human-friendly ISO 8601 format."""
120 return '%s %s' % (self.__time.replace(tzinfo = None).isoformat(' '),
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]:
128 return cls(datestring)
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)
151 return '%s %s' % (self.name_email, self.date)
154 m = re.match(r'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s)
156 name = m.group(1).strip()
158 date = Date(m.group(3))
159 return cls(name, email, date)
162 if not hasattr(cls, '__user'):
163 cls.__user = cls(name = config.get('user.name'),
164 email = config.get('user.email'))
168 if not hasattr(cls, '__author'):
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())
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),
182 os.environ.get('GIT_COMMITTER_DATE', NoValue)),
183 defaults = cls.user())
184 return cls.__committer
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."""
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):
199 @return: The committed blob
201 sha1 = repository.run(['git', 'hash-object', '-w', '--stdin']
202 ).raw_input(self.str).output_one_line()
203 return repository.get_blob(sha1)
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."""
210 default_perm = '100644'
211 def __init__(self, repository, sha1):
212 self.__repository = repository
214 sha1 = property(lambda self: self.__sha1)
216 return 'Blob<%s>' % self.sha1
219 return BlobData(self.__repository.cat_object(self.sha1))
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')
233 class TreeData(Immutable, Repr):
234 """Represents the data contents of a git tree object."""
237 if isinstance(po, GitObject):
238 perm, object = po.default_perm, po
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
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}.
254 @param name: Name of the changed mapping
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)
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.
267 @param name: Name of the deleted mapping
269 @return: The new L{TreeData} object
270 @rtype: L{TreeData}"""
271 e = dict(self.entries)
274 def commit(self, repository):
276 @return: The committed tree
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)
285 def parse(cls, repository, s):
286 """Parse a raw git tree description.
288 @return: A new L{TreeData} object
289 @rtype: L{TreeData}"""
291 for line in s.split('\0')[:-1]:
292 m = re.match(r'^([0-7]{6}) ([a-z]+) ([0-9a-f]{40})\t(.*)$', line)
294 perm, type, sha1, name = m.groups()
295 entries[name] = (perm, repository.get_object(type, sha1))
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."""
303 default_perm = '040000'
304 def __init__(self, repository, sha1):
306 self.__repository = repository
308 sha1 = property(lambda self: self.__sha1)
311 if self.__data == None:
312 self.__data = TreeData.parse(
314 self.__repository.run(['git', 'ls-tree', '-z', self.sha1]
318 return 'Tree<sha1: %s>' % self.sha1
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)
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],
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
357 if self.tree == None:
360 tree = self.tree.sha1
361 if self.parents == None:
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
372 c = ['git', 'commit-tree', self.tree.sha1]
373 for p in self.parents:
377 for p, v1 in ((self.author, 'AUTHOR'),
378 (self.committer, 'COMMITTER')):
380 for attr, v2 in (('name', 'NAME'), ('email', 'EMAIL'),
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
386 return repository.get_commit(sha1)
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()
397 return cd.set_message(''.join(lines[i+1:]))
398 key, value = line.split(None, 1)
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))
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."""
416 def __init__(self, repository, sha1):
418 self.__repository = repository
420 sha1 = property(lambda self: self.__sha1)
423 if self.__data == None:
424 self.__data = CommitData.parse(
426 self.__repository.cat_object(self.sha1))
429 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
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
437 def __cache_refs(self):
438 """(Re-)Build the cache of all refs in the repository."""
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
445 """Get the Commit the given ref points to. Throws KeyError if ref
447 if self.__refs == None:
449 return self.__repository.get_commit(self.__refs[ref])
450 def exists(self, ref):
451 """Check if the given ref exists."""
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:
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:
473 self.__repository.run(['git', 'update-ref',
474 '-d', ref, self.__refs[ref]]).no_output()
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):
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
494 class RunWithEnv(object):
495 def run(self, args, env = {}):
496 """Run the given command with an environment given by self.env.
498 @type args: list of strings
499 @param args: Command and argument vector
501 @param env: Extra environment"""
502 return run.Run(*args).env(utils.add_dict(self.env, env))
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.
509 @type args: list of strings
510 @param args: Command and argument vector
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
519 @type args: list of strings
520 @param args: Command and argument vector"""
521 return RunWithEnv.run(self, args, self.env_in_cwd)
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 })
537 """Return the default repository."""
539 return cls(run.Run('git', 'rev-parse', '--git-dir'
541 except run.RunException:
542 raise RepositoryException('Cannot find git repository')
544 def current_branch_name(self):
545 """Return the name of the current branch."""
546 return utils.strip_prefix('refs/heads/', self.head_ref)
548 def default_index(self):
549 """An L{Index} object representing the default index file for the
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)
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)
566 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
570 self.__default_worktree = Worktree(path)
571 return self.__default_worktree
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):
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)
606 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
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()
624 result, index_tree = index.merge(base, ours, theirs)
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)
635 index = self.temp_index()
637 index.read_tree(tree)
639 index.apply(patch_text, quiet)
640 return index.write_tree()
641 except MergeException:
645 def diff_tree(self, t1, t2, diff_opts):
646 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
649 @type diff_opts: list of strings
650 @param diff_opts: Extra diff options
652 @return: Patch text"""
653 assert isinstance(t1, Tree)
654 assert isinstance(t2, Tree)
655 return self.run(['git', 'diff-tree', '-p'] + list(diff_opts)
656 + [t1.sha1, t2.sha1]).raw_output()
657 def diff_tree_files(self, t1, t2):
658 """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
659 which they differ. For each file, yield a tuple with the old
660 file mode, the new file mode, the old blob, the new blob, the
661 status, the old filename, and the new filename. Except in case
662 of a copy or a rename, the old and new filenames are
664 assert isinstance(t1, Tree)
665 assert isinstance(t2, Tree)
666 i = iter(self.run(['git', 'diff-tree', '-r', '-z'] + [t1.sha1, t2.sha1]
667 ).raw_output().split('\0'))
673 omode, nmode, osha1, nsha1, status = x[1:].split(' ')
675 if status[0] in ['C', 'R']:
679 yield (omode, nmode, self.get_blob(osha1),
680 self.get_blob(nsha1), status, fn1, fn2)
681 except StopIteration:
684 class MergeException(exception.StgException):
685 """Exception raised when a merge fails for some reason."""
687 class MergeConflictException(MergeException):
688 """Exception raised when a merge fails due to conflicts."""
689 def __init__(self, conflicts):
690 MergeException.__init__(self)
691 self.conflicts = conflicts
693 class Index(RunWithEnv):
694 """Represents a git index file."""
695 def __init__(self, repository, filename):
696 self.__repository = repository
697 if os.path.isdir(filename):
698 # Create a temp index in the given directory.
699 self.__filename = os.path.join(
700 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
703 self.__filename = filename
704 env = property(lambda self: utils.add_dict(
705 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
706 def read_tree(self, tree):
707 self.run(['git', 'read-tree', tree.sha1]).no_output()
708 def write_tree(self):
709 """Write the index contents to the repository.
710 @return: The resulting L{Tree}
713 return self.__repository.get_tree(
714 self.run(['git', 'write-tree']).discard_stderr(
716 except run.RunException:
717 raise MergeException('Conflicting merge')
718 def is_clean(self, tree):
719 """Check whether the index is clean relative to the given treeish."""
721 self.run(['git', 'diff-index', '--quiet', '--cached', tree.sha1]
723 except run.RunException:
727 def apply(self, patch_text, quiet):
728 """In-index patch application, no worktree involved."""
730 r = self.run(['git', 'apply', '--cached']).raw_input(patch_text)
732 r = r.discard_stderr()
734 except run.RunException:
735 raise MergeException('Patch does not apply cleanly')
736 def apply_treediff(self, tree1, tree2, quiet):
737 """Apply the diff from C{tree1} to C{tree2} to the index."""
738 # Passing --full-index here is necessary to support binary
739 # files. It is also sufficient, since the repository already
740 # contains all involved objects; in other words, we don't have
742 self.apply(self.__repository.diff_tree(tree1, tree2, ['--full-index']),
744 def merge(self, base, ours, theirs, current = None):
745 """Use the index (and only the index) to do a 3-way merge of the
746 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
747 succeed (in which case the first half of the return value is
748 the resulting tree) or fail cleanly (in which case the first
749 half of the return value is C{None}).
751 If C{current} is given (and not C{None}), it is assumed to be
752 the L{Tree} currently stored in the index; this information is
753 used to avoid having to read the right tree (either of C{ours}
754 and C{theirs}) into the index if it's already there. The
755 second half of the return value is the tree now stored in the
756 index, or C{None} if unknown. If the merge succeeded, this is
757 often the merge result."""
758 assert isinstance(base, Tree)
759 assert isinstance(ours, Tree)
760 assert isinstance(theirs, Tree)
761 assert current == None or isinstance(current, Tree)
763 # Take care of the really trivial cases.
765 return (theirs, current)
767 return (ours, current)
769 return (ours, current)
771 if current == theirs:
772 # Swap the trees. It doesn't matter since merging is
773 # symmetric, and will allow us to avoid the read_tree()
775 ours, theirs = theirs, ours
779 self.apply_treediff(base, theirs, quiet = True)
780 result = self.write_tree()
781 return (result, result)
782 except MergeException:
785 if os.path.isfile(self.__filename):
786 os.remove(self.__filename)
788 """The set of conflicting paths."""
790 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
791 ).raw_output().split('\0')[:-1]:
792 stat, path = line.split('\t', 1)
796 class Worktree(object):
797 """Represents a git worktree (that is, a checked-out file tree)."""
798 def __init__(self, directory):
799 self.__directory = directory
800 env = property(lambda self: { 'GIT_WORK_TREE': '.' })
801 env_in_cwd = property(lambda self: { 'GIT_WORK_TREE': self.directory })
802 directory = property(lambda self: self.__directory)
804 class CheckoutException(exception.StgException):
805 """Exception raised when a checkout fails."""
807 class IndexAndWorktree(RunWithEnvCwd):
808 """Represents a git index and a worktree. Anything that an index or
809 worktree can do on their own are handled by the L{Index} and
810 L{Worktree} classes; this class concerns itself with the
811 operations that require both."""
812 def __init__(self, index, worktree):
814 self.__worktree = worktree
815 index = property(lambda self: self.__index)
816 env = property(lambda self: utils.add_dict(self.__index.env,
817 self.__worktree.env))
818 env_in_cwd = property(lambda self: self.__worktree.env_in_cwd)
819 cwd = property(lambda self: self.__worktree.directory)
820 def checkout_hard(self, tree):
821 assert isinstance(tree, Tree)
822 self.run(['git', 'read-tree', '--reset', '-u', tree.sha1]
824 def checkout(self, old_tree, new_tree):
825 # TODO: Optionally do a 3-way instead of doing nothing when we
826 # have a problem. Or maybe we should stash changes in a patch?
827 assert isinstance(old_tree, Tree)
828 assert isinstance(new_tree, Tree)
830 self.run(['git', 'read-tree', '-u', '-m',
831 '--exclude-per-directory=.gitignore',
832 old_tree.sha1, new_tree.sha1]
834 except run.RunException:
835 raise CheckoutException('Index/workdir dirty')
836 def merge(self, base, ours, theirs, interactive = False):
837 assert isinstance(base, Tree)
838 assert isinstance(ours, Tree)
839 assert isinstance(theirs, Tree)
841 r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
843 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
844 'GITHEAD_%s' % ours.sha1: 'current',
845 'GITHEAD_%s' % theirs.sha1: 'patched'})
847 output = r.output_lines()
849 # There were conflicts
853 conflicts = [l for l in output if l.startswith('CONFLICT')]
854 raise MergeConflictException(conflicts)
855 except run.RunException, e:
856 raise MergeException('Index/worktree dirty')
857 def mergetool(self, files = ()):
858 """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
859 any outstanding conflicts. If 'not files', all the files in an
860 unmerged state will be processed."""
861 self.run(['git', 'mergetool'] + list(files)).returns([0, 1]).run()
862 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
864 conflicts = ['CONFLICT ' + f for f in self.index.conflicts()]
866 raise MergeConflictException(conflicts)
867 def changed_files(self, tree, pathlimits = []):
868 """Return the set of files in the worktree that have changed with
869 respect to C{tree}. The listing is optionally restricted to
870 those files that match any of the path limiters given.
872 The path limiters are relative to the current working
873 directory; the returned file names are relative to the
875 assert isinstance(tree, Tree)
876 return set(self.run_in_cwd(
877 ['git', 'diff-index', tree.sha1, '--name-only', '-z', '--']
878 + list(pathlimits)).raw_output().split('\0')[:-1])
879 def update_index(self, paths):
880 """Update the index with files from the worktree. C{paths} is an
881 iterable of paths relative to the root of the repository."""
882 cmd = ['git', 'update-index', '--remove']
883 self.run(cmd + ['-z', '--stdin']
884 ).input_nulterm(paths).discard_output()
885 def worktree_clean(self):
886 """Check whether the worktree is clean relative to index."""
888 self.run(['git', 'update-index', '--refresh']).discard_output()
889 except run.RunException:
894 class Branch(object):
895 """Represents a Git branch."""
896 def __init__(self, repository, name):
897 self.__repository = repository
902 raise BranchException('%s: no such branch' % name)
904 name = property(lambda self: self.__name)
905 repository = property(lambda self: self.__repository)
908 return 'refs/heads/%s' % self.__name
911 return self.__repository.refs.get(self.__ref())
912 def set_head(self, commit, msg):
913 self.__repository.refs.set(self.__ref(), commit, msg)
915 def set_parent_remote(self, name):
916 value = config.set('branch.%s.remote' % self.__name, name)
917 def set_parent_branch(self, name):
918 if config.get('branch.%s.remote' % self.__name):
919 # Never set merge if remote is not set to avoid
920 # possibly-erroneous lookups into 'origin'
921 config.set('branch.%s.merge' % self.__name, name)
924 def create(cls, repository, name, create_at = None):
925 """Create a new Git branch and return the corresponding
928 branch = cls(repository, name)
929 except BranchException:
932 raise BranchException('%s: branch already exists' % name)
934 cmd = ['git', 'branch']
936 cmd.append(create_at.sha1)
937 repository.run(['git', 'branch', create_at.sha1]).discard_output()
939 return cls(repository, name)
942 """Return the diffstat of the supplied diff."""
943 return run.Run('git', 'apply', '--stat', '--summary'
944 ).raw_input(diff).raw_output()
946 def clone(remote, local):
947 """Clone a remote repository using 'git clone'."""
948 run.Run('git', 'clone', remote, local).run()