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