Log conflicts separately for all commands
[stgit] / stgit / lib / git.py
CommitLineData
652b2e67
KH
1"""A Python class hierarchy wrapping a git repository and its
2contents."""
3
cbe4567e 4import os, os.path, re
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))
407 else:
408 assert False
409 assert False
410
f40945d1 411class Commit(GitObject):
652b2e67
KH
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."""
f40945d1 415 typename = 'commit'
cbe4567e
KH
416 def __init__(self, repository, sha1):
417 self.__sha1 = sha1
418 self.__repository = repository
419 self.__data = None
420 sha1 = property(lambda self: self.__sha1)
421 @property
422 def data(self):
423 if self.__data == None:
f5f22afe 424 self.__data = CommitData.parse(
cbe4567e
KH
425 self.__repository,
426 self.__repository.cat_object(self.sha1))
427 return self.__data
428 def __str__(self):
429 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
430
431class Refs(object):
652b2e67
KH
432 """Accessor for the refs stored in a git repository. Will
433 transparently cache the values of all refs."""
cbe4567e
KH
434 def __init__(self, repository):
435 self.__repository = repository
436 self.__refs = None
437 def __cache_refs(self):
652b2e67 438 """(Re-)Build the cache of all refs in the repository."""
cbe4567e
KH
439 self.__refs = {}
440 for line in self.__repository.run(['git', 'show-ref']).output_lines():
441 m = re.match(r'^([0-9a-f]{40})\s+(\S+)$', line)
442 sha1, ref = m.groups()
443 self.__refs[ref] = sha1
444 def get(self, ref):
652b2e67
KH
445 """Get the Commit the given ref points to. Throws KeyError if ref
446 doesn't exist."""
cbe4567e
KH
447 if self.__refs == None:
448 self.__cache_refs()
449 return self.__repository.get_commit(self.__refs[ref])
f5c820a8 450 def exists(self, ref):
652b2e67 451 """Check if the given ref exists."""
f5c820a8
KH
452 try:
453 self.get(ref)
454 except KeyError:
455 return False
456 else:
457 return True
cbe4567e 458 def set(self, ref, commit, msg):
652b2e67
KH
459 """Write the sha1 of the given Commit to the ref. The ref may or may
460 not already exist."""
cbe4567e
KH
461 if self.__refs == None:
462 self.__cache_refs()
463 old_sha1 = self.__refs.get(ref, '0'*40)
464 new_sha1 = commit.sha1
465 if old_sha1 != new_sha1:
466 self.__repository.run(['git', 'update-ref', '-m', msg,
467 ref, new_sha1, old_sha1]).no_output()
468 self.__refs[ref] = new_sha1
469 def delete(self, ref):
652b2e67 470 """Delete the given ref. Throws KeyError if ref doesn't exist."""
cbe4567e
KH
471 if self.__refs == None:
472 self.__cache_refs()
473 self.__repository.run(['git', 'update-ref',
474 '-d', ref, self.__refs[ref]]).no_output()
475 del self.__refs[ref]
476
477class ObjectCache(object):
478 """Cache for Python objects, for making sure that we create only one
652b2e67
KH
479 Python object per git object. This reduces memory consumption and
480 makes object comparison very cheap."""
cbe4567e
KH
481 def __init__(self, create):
482 self.__objects = {}
483 self.__create = create
484 def __getitem__(self, name):
485 if not name in self.__objects:
486 self.__objects[name] = self.__create(name)
487 return self.__objects[name]
488 def __contains__(self, name):
489 return name in self.__objects
490 def __setitem__(self, name, val):
491 assert not name in self.__objects
492 self.__objects[name] = val
493
494class RunWithEnv(object):
495 def run(self, args, env = {}):
652b2e67
KH
496 """Run the given command with an environment given by self.env.
497
498 @type args: list of strings
499 @param args: Command and argument vector
500 @type env: dict
501 @param env: Extra environment"""
cbe4567e
KH
502 return run.Run(*args).env(utils.add_dict(self.env, env))
503
f83d4b2a
KH
504class RunWithEnvCwd(RunWithEnv):
505 def run(self, args, env = {}):
652b2e67
KH
506 """Run the given command with an environment given by self.env, and
507 current working directory given by self.cwd.
508
509 @type args: list of strings
510 @param args: Command and argument vector
511 @type env: dict
512 @param env: Extra environment"""
f83d4b2a
KH
513 return RunWithEnv.run(self, args, env).cwd(self.cwd)
514
cbe4567e 515class Repository(RunWithEnv):
652b2e67 516 """Represents a git repository."""
cbe4567e
KH
517 def __init__(self, directory):
518 self.__git_dir = directory
519 self.__refs = Refs(self)
f40945d1
KH
520 self.__blobs = ObjectCache(lambda sha1: Blob(self, sha1))
521 self.__trees = ObjectCache(lambda sha1: Tree(self, sha1))
cbe4567e 522 self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
a0848ecf
KH
523 self.__default_index = None
524 self.__default_worktree = None
525 self.__default_iw = None
cbe4567e
KH
526 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
527 @classmethod
528 def default(cls):
529 """Return the default repository."""
530 try:
531 return cls(run.Run('git', 'rev-parse', '--git-dir'
532 ).output_one_line())
533 except run.RunException:
534 raise RepositoryException('Cannot find git repository')
a0848ecf 535 @property
04c77bc5
CM
536 def current_branch_name(self):
537 """Return the name of the current branch."""
56555887 538 return utils.strip_prefix('refs/heads/', self.head_ref)
04c77bc5 539 @property
dcd32afa 540 def default_index(self):
652b2e67
KH
541 """An L{Index} object representing the default index file for the
542 repository."""
a0848ecf
KH
543 if self.__default_index == None:
544 self.__default_index = Index(
545 self, (os.environ.get('GIT_INDEX_FILE', None)
546 or os.path.join(self.__git_dir, 'index')))
547 return self.__default_index
dcd32afa 548 def temp_index(self):
652b2e67
KH
549 """Return an L{Index} object representing a new temporary index file
550 for the repository."""
dcd32afa 551 return Index(self, self.__git_dir)
a0848ecf 552 @property
dcd32afa 553 def default_worktree(self):
652b2e67 554 """A L{Worktree} object representing the default work tree."""
a0848ecf
KH
555 if self.__default_worktree == None:
556 path = os.environ.get('GIT_WORK_TREE', None)
557 if not path:
558 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
559 o = o or ['.']
560 assert len(o) == 1
561 path = o[0]
562 self.__default_worktree = Worktree(path)
563 return self.__default_worktree
564 @property
dcd32afa 565 def default_iw(self):
652b2e67
KH
566 """An L{IndexAndWorktree} object representing the default index and
567 work tree for this repository."""
a0848ecf
KH
568 if self.__default_iw == None:
569 self.__default_iw = IndexAndWorktree(self.default_index,
570 self.default_worktree)
571 return self.__default_iw
cbe4567e
KH
572 directory = property(lambda self: self.__git_dir)
573 refs = property(lambda self: self.__refs)
574 def cat_object(self, sha1):
575 return self.run(['git', 'cat-file', '-p', sha1]).raw_output()
48c930db 576 def rev_parse(self, rev, discard_stderr = False):
cbe4567e
KH
577 try:
578 return self.get_commit(self.run(
579 ['git', 'rev-parse', '%s^{commit}' % rev]
48c930db 580 ).discard_stderr(discard_stderr).output_one_line())
cbe4567e
KH
581 except run.RunException:
582 raise RepositoryException('%s: No such revision' % rev)
f40945d1
KH
583 def get_blob(self, sha1):
584 return self.__blobs[sha1]
cbe4567e
KH
585 def get_tree(self, sha1):
586 return self.__trees[sha1]
587 def get_commit(self, sha1):
588 return self.__commits[sha1]
f40945d1
KH
589 def get_object(self, type, sha1):
590 return { Blob.typename: self.get_blob,
591 Tree.typename: self.get_tree,
592 Commit.typename: self.get_commit }[type](sha1)
593 def commit(self, objectdata):
594 return objectdata.commit(self)
cbe4567e 595 @property
2b8d32ac 596 def head_ref(self):
cbe4567e
KH
597 try:
598 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
599 ).output_one_line()
600 except run.RunException:
601 raise DetachedHeadException()
2b8d32ac 602 def set_head_ref(self, ref, msg):
cbe4567e 603 self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
dcd32afa 604 def simple_merge(self, base, ours, theirs):
dcd32afa
KH
605 index = self.temp_index()
606 try:
afa3f9b9 607 result, index_tree = index.merge(base, ours, theirs)
dcd32afa
KH
608 finally:
609 index.delete()
afa3f9b9 610 return result
29d9a264 611 def apply(self, tree, patch_text, quiet):
652b2e67
KH
612 """Given a L{Tree} and a patch, will either return the new L{Tree}
613 that results when the patch is applied, or None if the patch
d5d8a4f0
KH
614 couldn't be applied."""
615 assert isinstance(tree, Tree)
616 if not patch_text:
617 return tree
618 index = self.temp_index()
619 try:
620 index.read_tree(tree)
621 try:
29d9a264 622 index.apply(patch_text, quiet)
d5d8a4f0
KH
623 return index.write_tree()
624 except MergeException:
625 return None
626 finally:
627 index.delete()
2558895a 628 def diff_tree(self, t1, t2, diff_opts):
652b2e67
KH
629 """Given two L{Tree}s C{t1} and C{t2}, return the patch that takes
630 C{t1} to C{t2}.
631
632 @type diff_opts: list of strings
633 @param diff_opts: Extra diff options
634 @rtype: String
635 @return: Patch text"""
2558895a
KH
636 assert isinstance(t1, Tree)
637 assert isinstance(t2, Tree)
638 return self.run(['git', 'diff-tree', '-p'] + list(diff_opts)
639 + [t1.sha1, t2.sha1]).raw_output()
dcd32afa
KH
640
641class MergeException(exception.StgException):
652b2e67 642 """Exception raised when a merge fails for some reason."""
dcd32afa 643
363d432f 644class MergeConflictException(MergeException):
652b2e67 645 """Exception raised when a merge fails due to conflicts."""
363d432f 646
dcd32afa 647class Index(RunWithEnv):
652b2e67 648 """Represents a git index file."""
dcd32afa
KH
649 def __init__(self, repository, filename):
650 self.__repository = repository
651 if os.path.isdir(filename):
652 # Create a temp index in the given directory.
653 self.__filename = os.path.join(
654 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
655 self.delete()
656 else:
657 self.__filename = filename
658 env = property(lambda self: utils.add_dict(
659 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
660 def read_tree(self, tree):
661 self.run(['git', 'read-tree', tree.sha1]).no_output()
662 def write_tree(self):
663 try:
664 return self.__repository.get_tree(
665 self.run(['git', 'write-tree']).discard_stderr(
666 ).output_one_line())
667 except run.RunException:
668 raise MergeException('Conflicting merge')
669 def is_clean(self):
670 try:
671 self.run(['git', 'update-index', '--refresh']).discard_output()
672 except run.RunException:
673 return False
674 else:
675 return True
29d9a264 676 def apply(self, patch_text, quiet):
d5d8a4f0
KH
677 """In-index patch application, no worktree involved."""
678 try:
29d9a264
KH
679 r = self.run(['git', 'apply', '--cached']).raw_input(patch_text)
680 if quiet:
681 r = r.discard_stderr()
682 r.no_output()
d5d8a4f0
KH
683 except run.RunException:
684 raise MergeException('Patch does not apply cleanly')
bc1ecd0b
KH
685 def apply_treediff(self, tree1, tree2, quiet):
686 """Apply the diff from C{tree1} to C{tree2} to the index."""
687 # Passing --full-index here is necessary to support binary
688 # files. It is also sufficient, since the repository already
689 # contains all involved objects; in other words, we don't have
690 # to use --binary.
691 self.apply(self.__repository.diff_tree(tree1, tree2, ['--full-index']),
692 quiet)
afa3f9b9
KH
693 def merge(self, base, ours, theirs, current = None):
694 """Use the index (and only the index) to do a 3-way merge of the
695 L{Tree}s C{base}, C{ours} and C{theirs}. The merge will either
696 succeed (in which case the first half of the return value is
697 the resulting tree) or fail cleanly (in which case the first
698 half of the return value is C{None}).
699
700 If C{current} is given (and not C{None}), it is assumed to be
701 the L{Tree} currently stored in the index; this information is
702 used to avoid having to read the right tree (either of C{ours}
703 and C{theirs}) into the index if it's already there. The
704 second half of the return value is the tree now stored in the
705 index, or C{None} if unknown. If the merge succeeded, this is
706 often the merge result."""
707 assert isinstance(base, Tree)
708 assert isinstance(ours, Tree)
709 assert isinstance(theirs, Tree)
710 assert current == None or isinstance(current, Tree)
711
712 # Take care of the really trivial cases.
713 if base == ours:
714 return (theirs, current)
715 if base == theirs:
716 return (ours, current)
717 if ours == theirs:
718 return (ours, current)
719
720 if current == theirs:
721 # Swap the trees. It doesn't matter since merging is
722 # symmetric, and will allow us to avoid the read_tree()
723 # call below.
724 ours, theirs = theirs, ours
725 if current != ours:
726 self.read_tree(ours)
727 try:
728 self.apply_treediff(base, theirs, quiet = True)
729 result = self.write_tree()
730 return (result, result)
731 except MergeException:
732 return (None, ours)
dcd32afa
KH
733 def delete(self):
734 if os.path.isfile(self.__filename):
735 os.remove(self.__filename)
a639e7bb
KH
736 def conflicts(self):
737 """The set of conflicting paths."""
738 paths = set()
739 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
740 ).raw_output().split('\0')[:-1]:
741 stat, path = line.split('\t', 1)
742 paths.add(path)
743 return paths
dcd32afa
KH
744
745class Worktree(object):
652b2e67 746 """Represents a git worktree (that is, a checked-out file tree)."""
dcd32afa
KH
747 def __init__(self, directory):
748 self.__directory = directory
f83d4b2a 749 env = property(lambda self: { 'GIT_WORK_TREE': '.' })
8d96d568 750 directory = property(lambda self: self.__directory)
dcd32afa
KH
751
752class CheckoutException(exception.StgException):
652b2e67 753 """Exception raised when a checkout fails."""
dcd32afa 754
f83d4b2a 755class IndexAndWorktree(RunWithEnvCwd):
652b2e67
KH
756 """Represents a git index and a worktree. Anything that an index or
757 worktree can do on their own are handled by the L{Index} and
758 L{Worktree} classes; this class concerns itself with the
759 operations that require both."""
dcd32afa
KH
760 def __init__(self, index, worktree):
761 self.__index = index
762 self.__worktree = worktree
763 index = property(lambda self: self.__index)
764 env = property(lambda self: utils.add_dict(self.__index.env,
765 self.__worktree.env))
f83d4b2a 766 cwd = property(lambda self: self.__worktree.directory)
dcd32afa
KH
767 def checkout(self, old_tree, new_tree):
768 # TODO: Optionally do a 3-way instead of doing nothing when we
769 # have a problem. Or maybe we should stash changes in a patch?
770 assert isinstance(old_tree, Tree)
771 assert isinstance(new_tree, Tree)
772 try:
773 self.run(['git', 'read-tree', '-u', '-m',
774 '--exclude-per-directory=.gitignore',
775 old_tree.sha1, new_tree.sha1]
f83d4b2a 776 ).discard_output()
dcd32afa
KH
777 except run.RunException:
778 raise CheckoutException('Index/workdir dirty')
779 def merge(self, base, ours, theirs):
780 assert isinstance(base, Tree)
781 assert isinstance(ours, Tree)
782 assert isinstance(theirs, Tree)
783 try:
363d432f
KH
784 r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
785 theirs.sha1],
786 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
787 'GITHEAD_%s' % ours.sha1: 'current',
f83d4b2a 788 'GITHEAD_%s' % theirs.sha1: 'patched'})
363d432f 789 r.discard_output()
dcd32afa 790 except run.RunException, e:
363d432f
KH
791 if r.exitcode == 1:
792 raise MergeConflictException()
793 else:
794 raise MergeException('Index/worktree dirty')
dcd32afa
KH
795 def changed_files(self):
796 return self.run(['git', 'diff-files', '--name-only']).output_lines()
797 def update_index(self, files):
798 self.run(['git', 'update-index', '--remove', '-z', '--stdin']
799 ).input_nulterm(files).discard_output()
04c77bc5
CM
800
801class Branch(object):
802 """Represents a Git branch."""
803 def __init__(self, repository, name):
804 self.__repository = repository
805 self.__name = name
806 try:
807 self.head
808 except KeyError:
809 raise BranchException('%s: no such branch' % name)
810
811 name = property(lambda self: self.__name)
812 repository = property(lambda self: self.__repository)
813
814 def __ref(self):
815 return 'refs/heads/%s' % self.__name
816 @property
817 def head(self):
818 return self.__repository.refs.get(self.__ref())
819 def set_head(self, commit, msg):
820 self.__repository.refs.set(self.__ref(), commit, msg)
821
822 def set_parent_remote(self, name):
823 value = config.set('branch.%s.remote' % self.__name, name)
824 def set_parent_branch(self, name):
825 if config.get('branch.%s.remote' % self.__name):
826 # Never set merge if remote is not set to avoid
827 # possibly-erroneous lookups into 'origin'
828 config.set('branch.%s.merge' % self.__name, name)
829
830 @classmethod
831 def create(cls, repository, name, create_at = None):
832 """Create a new Git branch and return the corresponding
833 L{Branch} object."""
834 try:
835 branch = cls(repository, name)
836 except BranchException:
837 branch = None
838 if branch:
839 raise BranchException('%s: branch already exists' % name)
840
841 cmd = ['git', 'branch']
842 if create_at:
843 cmd.append(create_at.sha1)
844 repository.run(['git', 'branch', create_at.sha1]).discard_output()
845
846 return cls(repository, name)