Remove the assert in CommitData.parse() function
[stgit] / stgit / lib / git.py
CommitLineData
652b2e67
KH
1"""A Python class hierarchy wrapping a git repository and its
2contents."""
3
f0b6dda7 4import atexit, os, os.path, re, signal
a6a5abd8
KH
5from datetime import datetime, timedelta, tzinfo
6
cbe4567e 7from stgit import exception, run, utils
2057c23e 8from stgit.config import config
cbe4567e 9
652b2e67
KH
10class 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
f8ff2d3e 23 The L{Immutable} class doesn't actually enforce immutability --
652b2e67
KH
24 that is up to the individual immutable subclasses. It just serves
25 as documentation."""
26
cbe4567e 27class RepositoryException(exception.StgException):
652b2e67
KH
28 """Base class for all exceptions due to failed L{Repository}
29 operations."""
cbe4567e 30
04c77bc5
CM
31class BranchException(exception.StgException):
32 """Exception raised by failed L{Branch} operations."""
33
a6a5abd8 34class DateException(exception.StgException):
652b2e67 35 """Exception raised when a date+time string could not be parsed."""
a6a5abd8
KH
36 def __init__(self, string, type):
37 exception.StgException.__init__(
38 self, '"%s" is not a valid %s' % (string, type))
39
cbe4567e 40class DetachedHeadException(RepositoryException):
652b2e67
KH
41 """Exception raised when HEAD is detached (that is, there is no
42 current branch)."""
cbe4567e
KH
43 def __init__(self):
44 RepositoryException.__init__(self, 'Not on any branch')
45
46class Repr(object):
652b2e67 47 """Utility class that defines C{__reps__} in terms of C{__str__}."""
cbe4567e
KH
48 def __repr__(self):
49 return str(self)
50
51class NoValue(object):
652b2e67
KH
52 """A handy default value that is guaranteed to be distinct from any
53 real argument value."""
cbe4567e
KH
54 pass
55
56def make_defaults(defaults):
4ed9cbbc 57 def d(val, attr, default_fun = lambda: None):
cbe4567e
KH
58 if val != NoValue:
59 return val
60 elif defaults != NoValue:
61 return getattr(defaults, attr)
62 else:
4ed9cbbc 63 return default_fun()
cbe4567e
KH
64 return d
65
a6a5abd8 66class TimeZone(tzinfo, Repr):
652b2e67
KH
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.)"""
a6a5abd8
KH
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
652b2e67
KH
90class Date(Immutable, Repr):
91 """Represents a timestamp used in git commits."""
a6a5abd8
KH
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):
652b2e67
KH
124 """Return a new object initialized with the argument if it contains a
125 value (otherwise, just return the argument)."""
a6a5abd8
KH
126 if datestring in [None, NoValue]:
127 return datestring
128 return cls(datestring)
129
652b2e67
KH
130class Person(Immutable, Repr):
131 """Represents an author or committer in a git commit object. Contains
132 name, email and timestamp."""
cbe4567e
KH
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')
a6a5abd8 139 assert isinstance(self.__date, Date) or self.__date in [None, NoValue]
cbe4567e
KH
140 name = property(lambda self: self.__name)
141 email = property(lambda self: self.__email)
117ed129 142 name_email = property(lambda self: '%s <%s>' % (self.name, self.email))
cbe4567e
KH
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):
117ed129 151 return '%s %s' % (self.name_email, self.date)
cbe4567e
KH
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)
a6a5abd8 158 date = Date(m.group(3))
cbe4567e 159 return cls(name, email, date)
2057c23e
KH
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),
a6a5abd8 172 date = Date.maybe(os.environ.get('GIT_AUTHOR_DATE', NoValue)),
2057c23e
KH
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),
a6a5abd8
KH
181 date = Date.maybe(
182 os.environ.get('GIT_COMMITTER_DATE', NoValue)),
2057c23e
KH
183 defaults = cls.user())
184 return cls.__committer
cbe4567e 185
f40945d1
KH
186class 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
192class 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
205class 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
cbe4567e
KH
213 self.__sha1 = sha1
214 sha1 = property(lambda self: self.__sha1)
215 def __str__(self):
f40945d1
KH
216 return 'Blob<%s>' % self.sha1
217 @property
218 def data(self):
219 return BlobData(self.__repository.cat_object(self.sha1))
220
221class 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
233class 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
298class 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
cbe4567e 319
652b2e67 320class CommitData(Immutable, Repr):
f40945d1 321 """Represents the data contents of a git commit object."""
cbe4567e
KH
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')
4ed9cbbc
KH
327 self.__author = d(author, 'author', Person.author)
328 self.__committer = d(committer, 'committer', Person.committer)
cbe4567e
KH
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)
dcd32afa
KH
354 def is_nochange(self):
355 return len(self.parents) == 1 and self.tree == self.parent.data.tree
cbe4567e
KH
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]
f5f22afe 365 return ('CommitData<tree: %s, parents: %s, author: %s,'
cbe4567e
KH
366 ' committer: %s, message: "%s">'
367 ) % (tree, parents, self.author, self.committer, self.message)
f40945d1
KH
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)
cbe4567e
KH
387 @classmethod
388 def parse(cls, repository, s):
f40945d1
KH
389 """Parse a raw git commit description.
390 @return: A new L{CommitData} object
391 @rtype: L{CommitData}"""
5b7169c0 392 cd = cls(parents = [])
cbe4567e
KH
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))
cbe4567e
KH
407 assert False
408
f40945d1 409class Commit(GitObject):
652b2e67
KH
410 """Represents a git commit object. All the actual data contents of the
411 commit object is stored in the L{data} member, which is a
412 L{CommitData} object."""
f40945d1 413 typename = 'commit'
cbe4567e
KH
414 def __init__(self, repository, sha1):
415 self.__sha1 = sha1
416 self.__repository = repository
417 self.__data = None
418 sha1 = property(lambda self: self.__sha1)
419 @property
420 def data(self):
421 if self.__data == None:
f5f22afe 422 self.__data = CommitData.parse(
cbe4567e
KH
423 self.__repository,
424 self.__repository.cat_object(self.sha1))
425 return self.__data
426 def __str__(self):
427 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
428
429class Refs(object):
652b2e67
KH
430 """Accessor for the refs stored in a git repository. Will
431 transparently cache the values of all refs."""
cbe4567e
KH
432 def __init__(self, repository):
433 self.__repository = repository
434 self.__refs = None
435 def __cache_refs(self):
652b2e67 436 """(Re-)Build the cache of all refs in the repository."""
cbe4567e
KH
437 self.__refs = {}
438 for line in self.__repository.run(['git', 'show-ref']).output_lines():
439 m = re.match(r'^([0-9a-f]{40})\s+(\S+)$', line)
440 sha1, ref = m.groups()
441 self.__refs[ref] = sha1
442 def get(self, ref):
652b2e67
KH
443 """Get the Commit the given ref points to. Throws KeyError if ref
444 doesn't exist."""
cbe4567e
KH
445 if self.__refs == None:
446 self.__cache_refs()
447 return self.__repository.get_commit(self.__refs[ref])
f5c820a8 448 def exists(self, ref):
652b2e67 449 """Check if the given ref exists."""
f5c820a8
KH
450 try:
451 self.get(ref)
452 except KeyError:
453 return False
454 else:
455 return True
cbe4567e 456 def set(self, ref, commit, msg):
652b2e67
KH
457 """Write the sha1 of the given Commit to the ref. The ref may or may
458 not already exist."""
cbe4567e
KH
459 if self.__refs == None:
460 self.__cache_refs()
461 old_sha1 = self.__refs.get(ref, '0'*40)
462 new_sha1 = commit.sha1
463 if old_sha1 != new_sha1:
464 self.__repository.run(['git', 'update-ref', '-m', msg,
465 ref, new_sha1, old_sha1]).no_output()
466 self.__refs[ref] = new_sha1
467 def delete(self, ref):
652b2e67 468 """Delete the given ref. Throws KeyError if ref doesn't exist."""
cbe4567e
KH
469 if self.__refs == None:
470 self.__cache_refs()
471 self.__repository.run(['git', 'update-ref',
472 '-d', ref, self.__refs[ref]]).no_output()
473 del self.__refs[ref]
474
475class ObjectCache(object):
476 """Cache for Python objects, for making sure that we create only one
652b2e67
KH
477 Python object per git object. This reduces memory consumption and
478 makes object comparison very cheap."""
cbe4567e
KH
479 def __init__(self, create):
480 self.__objects = {}
481 self.__create = create
482 def __getitem__(self, name):
483 if not name in self.__objects:
484 self.__objects[name] = self.__create(name)
485 return self.__objects[name]
486 def __contains__(self, name):
487 return name in self.__objects
488 def __setitem__(self, name, val):
489 assert not name in self.__objects
490 self.__objects[name] = val
491
492class RunWithEnv(object):
493 def run(self, args, env = {}):
652b2e67
KH
494 """Run the given command with an environment given by self.env.
495
496 @type args: list of strings
497 @param args: Command and argument vector
498 @type env: dict
499 @param env: Extra environment"""
cbe4567e
KH
500 return run.Run(*args).env(utils.add_dict(self.env, env))
501
f83d4b2a
KH
502class RunWithEnvCwd(RunWithEnv):
503 def run(self, args, env = {}):
652b2e67
KH
504 """Run the given command with an environment given by self.env, and
505 current working directory given by self.cwd.
506
507 @type args: list of strings
508 @param args: Command and argument vector
509 @type env: dict
510 @param env: Extra environment"""
f83d4b2a 511 return RunWithEnv.run(self, args, env).cwd(self.cwd)
85aaed81
KH
512 def run_in_cwd(self, args):
513 """Run the given command with an environment given by self.env and
514 self.env_in_cwd, without changing the current working
515 directory.
516
517 @type args: list of strings
518 @param args: Command and argument vector"""
519 return RunWithEnv.run(self, args, self.env_in_cwd)
f83d4b2a 520
f0b6dda7
KW
521class CatFileProcess(object):
522 def __init__(self, repo):
523 self.__repo = repo
524 self.__proc = None
525 atexit.register(self.__shutdown)
526 def __get_process(self):
527 if not self.__proc:
528 self.__proc = self.__repo.run(['git', 'cat-file', '--batch']
529 ).run_background()
530 return self.__proc
531 def __shutdown(self):
532 p = self.__proc
533 if p:
534 os.kill(p.pid(), signal.SIGTERM)
535 p.wait()
536 def cat_file(self, sha1):
537 p = self.__get_process()
538 p.stdin.write('%s\n' % sha1)
539 p.stdin.flush()
540
541 # Read until we have the entire status line.
542 s = ''
543 while not '\n' in s:
544 s += os.read(p.stdout.fileno(), 4096)
545 h, b = s.split('\n', 1)
546 if h == '%s missing' % sha1:
547 raise SomeException()
548 hash, type, length = h.split()
549 assert hash == sha1
550 length = int(length)
551
552 # Read until we have the entire object plus the trailing
553 # newline.
554 while len(b) < length + 1:
555 b += os.read(p.stdout.fileno(), 4096)
556 return type, b[:-1]
557
cbe4567e 558class Repository(RunWithEnv):
652b2e67 559 """Represents a git repository."""
cbe4567e
KH
560 def __init__(self, directory):
561 self.__git_dir = directory
562 self.__refs = Refs(self)
f40945d1
KH
563 self.__blobs = ObjectCache(lambda sha1: Blob(self, sha1))
564 self.__trees = ObjectCache(lambda sha1: Tree(self, sha1))
cbe4567e 565 self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
a0848ecf
KH
566 self.__default_index = None
567 self.__default_worktree = None
568 self.__default_iw = None
f0b6dda7 569 self.__catfile = CatFileProcess(self)
cbe4567e
KH
570 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
571 @classmethod
572 def default(cls):
573 """Return the default repository."""
574 try:
575 return cls(run.Run('git', 'rev-parse', '--git-dir'
576 ).output_one_line())
577 except run.RunException:
578 raise RepositoryException('Cannot find git repository')
a0848ecf 579 @property
04c77bc5
CM
580 def current_branch_name(self):
581 """Return the name of the current branch."""
56555887 582 return utils.strip_prefix('refs/heads/', self.head_ref)
04c77bc5 583 @property
dcd32afa 584 def default_index(self):
652b2e67
KH
585 """An L{Index} object representing the default index file for the
586 repository."""
a0848ecf
KH
587 if self.__default_index == None:
588 self.__default_index = Index(
589 self, (os.environ.get('GIT_INDEX_FILE', None)
590 or os.path.join(self.__git_dir, 'index')))
591 return self.__default_index
dcd32afa 592 def temp_index(self):
652b2e67
KH
593 """Return an L{Index} object representing a new temporary index file
594 for the repository."""
dcd32afa 595 return Index(self, self.__git_dir)
a0848ecf 596 @property
dcd32afa 597 def default_worktree(self):
652b2e67 598 """A L{Worktree} object representing the default work tree."""
a0848ecf
KH
599 if self.__default_worktree == None:
600 path = os.environ.get('GIT_WORK_TREE', None)
601 if not path:
602 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
603 o = o or ['.']
604 assert len(o) == 1
605 path = o[0]
606 self.__default_worktree = Worktree(path)
607 return self.__default_worktree
608 @property
dcd32afa 609 def default_iw(self):
652b2e67
KH
610 """An L{IndexAndWorktree} object representing the default index and
611 work tree for this repository."""
a0848ecf
KH
612 if self.__default_iw == None:
613 self.__default_iw = IndexAndWorktree(self.default_index,
614 self.default_worktree)
615 return self.__default_iw
cbe4567e
KH
616 directory = property(lambda self: self.__git_dir)
617 refs = property(lambda self: self.__refs)
618 def cat_object(self, sha1):
f0b6dda7 619 return self.__catfile.cat_file(sha1)[1]
5fd79c5f
GH
620 def rev_parse(self, rev, discard_stderr = False, object_type = 'commit'):
621 assert object_type in ('commit', 'tree', 'blob')
622 getter = getattr(self, 'get_' + object_type)
cbe4567e 623 try:
5fd79c5f
GH
624 return getter(self.run(
625 ['git', 'rev-parse', '%s^{%s}' % (rev, object_type)]
48c930db 626 ).discard_stderr(discard_stderr).output_one_line())
cbe4567e 627 except run.RunException:
5fd79c5f 628 raise RepositoryException('%s: No such %s' % (rev, object_type))
f40945d1
KH
629 def get_blob(self, sha1):
630 return self.__blobs[sha1]
cbe4567e
KH
631 def get_tree(self, sha1):
632 return self.__trees[sha1]
633 def get_commit(self, sha1):
634 return self.__commits[sha1]
f40945d1
KH
635 def get_object(self, type, sha1):
636 return { Blob.typename: self.get_blob,
637 Tree.typename: self.get_tree,
638 Commit.typename: self.get_commit }[type](sha1)
639 def commit(self, objectdata):
640 return objectdata.commit(self)
cbe4567e 641 @property
2b8d32ac 642 def head_ref(self):
cbe4567e
KH
643 try:
644 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
645 ).output_one_line()
646 except run.RunException:
647 raise DetachedHeadException()
2b8d32ac 648 def set_head_ref(self, ref, msg):
cbe4567e 649 self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
e58f264a
CM
650 def get_merge_bases(self, commit1, commit2):
651 """Return a set of merge bases of two commits."""
652 sha1_list = self.run(['git', 'merge-base', '--all',
653 commit1.sha1, commit2.sha1]).output_lines()
32a4e200 654 return [self.get_commit(sha1) for sha1 in sha1_list]
e58f264a
CM
655 def describe(self, commit):
656 """Use git describe --all on the given commit."""
657 return self.run(['git', 'describe', '--all', commit.sha1]
9a4a9c2c 658 ).discard_stderr().discard_exitcode().raw_output()
dcd32afa 659 def simple_merge(self, base, ours, theirs):
dcd32afa
KH
660 index = self.temp_index()
661 try:
afa3f9b9 662 result, index_tree = index.merge(base, ours, theirs)
dcd32afa
KH
663 finally:
664 index.delete()
afa3f9b9 665 return result
29d9a264 666 def apply(self, tree, patch_text, quiet):
652b2e67
KH
667 """Given a L{Tree} and a patch, will either return the new L{Tree}
668 that results when the patch is applied, or None if the patch
d5d8a4f0
KH
669 couldn't be applied."""
670 assert isinstance(tree, Tree)
671 if not patch_text:
672 return tree
673 index = self.temp_index()
674 try:
675 index.read_tree(tree)
676 try:
29d9a264 677 index.apply(patch_text, quiet)
d5d8a4f0
KH
678 return index.write_tree()
679 except MergeException:
680 return None
681 finally:
682 index.delete()
8bad4519 683 def diff_tree(self, t1, t2, diff_opts, binary = True):
652b2e67
KH
684 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
685 C{t1} to C{t2}.
686
687 @type diff_opts: list of strings
688 @param diff_opts: Extra diff options
689 @rtype: String
690 @return: Patch text"""
2558895a
KH
691 assert isinstance(t1, Tree)
692 assert isinstance(t2, Tree)
8bad4519
CM
693 diff_opts = list(diff_opts)
694 if binary and not '--binary' in diff_opts:
695 diff_opts.append('--binary')
696 return self.run(['git', 'diff-tree', '-p'] + diff_opts
2558895a 697 + [t1.sha1, t2.sha1]).raw_output()
85aaed81
KH
698 def diff_tree_files(self, t1, t2):
699 """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
700 which they differ. For each file, yield a tuple with the old
701 file mode, the new file mode, the old blob, the new blob, the
702 status, the old filename, and the new filename. Except in case
703 of a copy or a rename, the old and new filenames are
704 identical."""
705 assert isinstance(t1, Tree)
706 assert isinstance(t2, Tree)
707 i = iter(self.run(['git', 'diff-tree', '-r', '-z'] + [t1.sha1, t2.sha1]
708 ).raw_output().split('\0'))
709 try:
710 while True:
711 x = i.next()
712 if not x:
713 continue
714 omode, nmode, osha1, nsha1, status = x[1:].split(' ')
715 fn1 = i.next()
716 if status[0] in ['C', 'R']:
717 fn2 = i.next()
718 else:
719 fn2 = fn1
720 yield (omode, nmode, self.get_blob(osha1),
721 self.get_blob(nsha1), status, fn1, fn2)
722 except StopIteration:
723 pass
dcd32afa
KH
724
725class MergeException(exception.StgException):
652b2e67 726 """Exception raised when a merge fails for some reason."""
dcd32afa 727
363d432f 728class MergeConflictException(MergeException):
652b2e67 729 """Exception raised when a merge fails due to conflicts."""
a79cd5d5
CM
730 def __init__(self, conflicts):
731 MergeException.__init__(self)
732 self.conflicts = conflicts
363d432f 733
dcd32afa 734class Index(RunWithEnv):
652b2e67 735 """Represents a git index file."""
dcd32afa
KH
736 def __init__(self, repository, filename):
737 self.__repository = repository
738 if os.path.isdir(filename):
739 # Create a temp index in the given directory.
740 self.__filename = os.path.join(
741 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
742 self.delete()
743 else:
744 self.__filename = filename
745 env = property(lambda self: utils.add_dict(
746 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
747 def read_tree(self, tree):
748 self.run(['git', 'read-tree', tree.sha1]).no_output()
749 def write_tree(self):
85aaed81
KH
750 """Write the index contents to the repository.
751 @return: The resulting L{Tree}
752 @rtype: L{Tree}"""
dcd32afa
KH
753 try:
754 return self.__repository.get_tree(
755 self.run(['git', 'write-tree']).discard_stderr(
756 ).output_one_line())
757 except run.RunException:
758 raise MergeException('Conflicting merge')
ee11a289
CM
759 def is_clean(self, tree):
760 """Check whether the index is clean relative to the given treeish."""
dcd32afa 761 try:
ee11a289
CM
762 self.run(['git', 'diff-index', '--quiet', '--cached', tree.sha1]
763 ).discard_output()
dcd32afa
KH
764 except run.RunException:
765 return False
766 else:
767 return True
29d9a264 768 def apply(self, patch_text, quiet):
d5d8a4f0
KH
769 """In-index patch application, no worktree involved."""
770 try:
29d9a264
KH
771 r = self.run(['git', 'apply', '--cached']).raw_input(patch_text)
772 if quiet:
773 r = r.discard_stderr()
774 r.no_output()
d5d8a4f0
KH
775 except run.RunException:
776 raise MergeException('Patch does not apply cleanly')
bc1ecd0b
KH
777 def apply_treediff(self, tree1, tree2, quiet):
778 """Apply the diff from C{tree1} to C{tree2} to the index."""
779 # Passing --full-index here is necessary to support binary
780 # files. It is also sufficient, since the repository already
781 # contains all involved objects; in other words, we don't have
782 # to use --binary.
783 self.apply(self.__repository.diff_tree(tree1, tree2, ['--full-index']),
784 quiet)
afa3f9b9
KH
785 def merge(self, base, ours, theirs, current = None):
786 """Use the index (and only the index) to do a 3-way merge of the
787 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
788 succeed (in which case the first half of the return value is
789 the resulting tree) or fail cleanly (in which case the first
790 half of the return value is C{None}).
791
792 If C{current} is given (and not C{None}), it is assumed to be
793 the L{Tree} currently stored in the index; this information is
794 used to avoid having to read the right tree (either of C{ours}
795 and C{theirs}) into the index if it's already there. The
796 second half of the return value is the tree now stored in the
797 index, or C{None} if unknown. If the merge succeeded, this is
798 often the merge result."""
799 assert isinstance(base, Tree)
800 assert isinstance(ours, Tree)
801 assert isinstance(theirs, Tree)
802 assert current == None or isinstance(current, Tree)
803
804 # Take care of the really trivial cases.
805 if base == ours:
806 return (theirs, current)
807 if base == theirs:
808 return (ours, current)
809 if ours == theirs:
810 return (ours, current)
811
812 if current == theirs:
813 # Swap the trees. It doesn't matter since merging is
814 # symmetric, and will allow us to avoid the read_tree()
815 # call below.
816 ours, theirs = theirs, ours
817 if current != ours:
818 self.read_tree(ours)
819 try:
820 self.apply_treediff(base, theirs, quiet = True)
821 result = self.write_tree()
822 return (result, result)
823 except MergeException:
824 return (None, ours)
dcd32afa
KH
825 def delete(self):
826 if os.path.isfile(self.__filename):
827 os.remove(self.__filename)
a639e7bb
KH
828 def conflicts(self):
829 """The set of conflicting paths."""
830 paths = set()
831 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
832 ).raw_output().split('\0')[:-1]:
833 stat, path = line.split('\t', 1)
834 paths.add(path)
835 return paths
dcd32afa
KH
836
837class Worktree(object):
652b2e67 838 """Represents a git worktree (that is, a checked-out file tree)."""
dcd32afa
KH
839 def __init__(self, directory):
840 self.__directory = directory
f83d4b2a 841 env = property(lambda self: { 'GIT_WORK_TREE': '.' })
85aaed81 842 env_in_cwd = property(lambda self: { 'GIT_WORK_TREE': self.directory })
8d96d568 843 directory = property(lambda self: self.__directory)
dcd32afa
KH
844
845class CheckoutException(exception.StgException):
652b2e67 846 """Exception raised when a checkout fails."""
dcd32afa 847
f83d4b2a 848class IndexAndWorktree(RunWithEnvCwd):
652b2e67
KH
849 """Represents a git index and a worktree. Anything that an index or
850 worktree can do on their own are handled by the L{Index} and
851 L{Worktree} classes; this class concerns itself with the
852 operations that require both."""
dcd32afa
KH
853 def __init__(self, index, worktree):
854 self.__index = index
855 self.__worktree = worktree
856 index = property(lambda self: self.__index)
857 env = property(lambda self: utils.add_dict(self.__index.env,
858 self.__worktree.env))
85aaed81 859 env_in_cwd = property(lambda self: self.__worktree.env_in_cwd)
f83d4b2a 860 cwd = property(lambda self: self.__worktree.directory)
9690617a
KH
861 def checkout_hard(self, tree):
862 assert isinstance(tree, Tree)
863 self.run(['git', 'read-tree', '--reset', '-u', tree.sha1]
864 ).discard_output()
dcd32afa
KH
865 def checkout(self, old_tree, new_tree):
866 # TODO: Optionally do a 3-way instead of doing nothing when we
867 # have a problem. Or maybe we should stash changes in a patch?
868 assert isinstance(old_tree, Tree)
869 assert isinstance(new_tree, Tree)
870 try:
871 self.run(['git', 'read-tree', '-u', '-m',
872 '--exclude-per-directory=.gitignore',
873 old_tree.sha1, new_tree.sha1]
f83d4b2a 874 ).discard_output()
dcd32afa
KH
875 except run.RunException:
876 raise CheckoutException('Index/workdir dirty')
1de97e5f 877 def merge(self, base, ours, theirs, interactive = False):
dcd32afa
KH
878 assert isinstance(base, Tree)
879 assert isinstance(ours, Tree)
880 assert isinstance(theirs, Tree)
881 try:
363d432f
KH
882 r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
883 theirs.sha1],
884 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
885 'GITHEAD_%s' % ours.sha1: 'current',
f83d4b2a 886 'GITHEAD_%s' % theirs.sha1: 'patched'})
a79cd5d5
CM
887 r.returns([0, 1])
888 output = r.output_lines()
889 if r.exitcode:
890 # There were conflicts
1de97e5f
CM
891 if interactive:
892 self.mergetool()
893 else:
894 conflicts = [l for l in output if l.startswith('CONFLICT')]
895 raise MergeConflictException(conflicts)
dcd32afa 896 except run.RunException, e:
a79cd5d5 897 raise MergeException('Index/worktree dirty')
1de97e5f
CM
898 def mergetool(self, files = ()):
899 """Invoke 'git mergetool' on the current IndexAndWorktree to resolve
900 any outstanding conflicts. If 'not files', all the files in an
901 unmerged state will be processed."""
902 self.run(['git', 'mergetool'] + list(files)).returns([0, 1]).run()
903 # check for unmerged entries (prepend 'CONFLICT ' for consistency with
904 # merge())
905 conflicts = ['CONFLICT ' + f for f in self.index.conflicts()]
906 if conflicts:
907 raise MergeConflictException(conflicts)
85aaed81
KH
908 def changed_files(self, tree, pathlimits = []):
909 """Return the set of files in the worktree that have changed with
910 respect to C{tree}. The listing is optionally restricted to
911 those files that match any of the path limiters given.
912
913 The path limiters are relative to the current working
914 directory; the returned file names are relative to the
915 repository root."""
916 assert isinstance(tree, Tree)
917 return set(self.run_in_cwd(
918 ['git', 'diff-index', tree.sha1, '--name-only', '-z', '--']
919 + list(pathlimits)).raw_output().split('\0')[:-1])
920 def update_index(self, paths):
921 """Update the index with files from the worktree. C{paths} is an
922 iterable of paths relative to the root of the repository."""
923 cmd = ['git', 'update-index', '--remove']
924 self.run(cmd + ['-z', '--stdin']
925 ).input_nulterm(paths).discard_output()
ee11a289
CM
926 def worktree_clean(self):
927 """Check whether the worktree is clean relative to index."""
928 try:
929 self.run(['git', 'update-index', '--refresh']).discard_output()
930 except run.RunException:
931 return False
932 else:
933 return True
04c77bc5
CM
934
935class Branch(object):
936 """Represents a Git branch."""
937 def __init__(self, repository, name):
938 self.__repository = repository
939 self.__name = name
940 try:
941 self.head
942 except KeyError:
943 raise BranchException('%s: no such branch' % name)
944
945 name = property(lambda self: self.__name)
946 repository = property(lambda self: self.__repository)
947
948 def __ref(self):
949 return 'refs/heads/%s' % self.__name
950 @property
951 def head(self):
952 return self.__repository.refs.get(self.__ref())
953 def set_head(self, commit, msg):
954 self.__repository.refs.set(self.__ref(), commit, msg)
955
956 def set_parent_remote(self, name):
957 value = config.set('branch.%s.remote' % self.__name, name)
958 def set_parent_branch(self, name):
959 if config.get('branch.%s.remote' % self.__name):
960 # Never set merge if remote is not set to avoid
961 # possibly-erroneous lookups into 'origin'
962 config.set('branch.%s.merge' % self.__name, name)
963
964 @classmethod
965 def create(cls, repository, name, create_at = None):
966 """Create a new Git branch and return the corresponding
967 L{Branch} object."""
968 try:
969 branch = cls(repository, name)
970 except BranchException:
971 branch = None
972 if branch:
973 raise BranchException('%s: branch already exists' % name)
974
975 cmd = ['git', 'branch']
976 if create_at:
977 cmd.append(create_at.sha1)
978 repository.run(['git', 'branch', create_at.sha1]).discard_output()
979
980 return cls(repository, name)
ef954fe6
KH
981
982def diffstat(diff):
983 """Return the diffstat of the supplied diff."""
984 return run.Run('git', 'apply', '--stat', '--summary'
985 ).raw_input(diff).raw_output()
78b90ced
CM
986
987def clone(remote, local):
988 """Clone a remote repository using 'git clone'."""
989 run.Run('git', 'clone', remote, local).run()