Merge branch 'stable' into stable-master-merge
[stgit] / stgit / lib / git.py
CommitLineData
cbe4567e 1import os, os.path, re
a6a5abd8
KH
2from datetime import datetime, timedelta, tzinfo
3
cbe4567e 4from stgit import exception, run, utils
2057c23e 5from stgit.config import config
cbe4567e
KH
6
7class RepositoryException(exception.StgException):
8 pass
9
a6a5abd8
KH
10class DateException(exception.StgException):
11 def __init__(self, string, type):
12 exception.StgException.__init__(
13 self, '"%s" is not a valid %s' % (string, type))
14
cbe4567e
KH
15class DetachedHeadException(RepositoryException):
16 def __init__(self):
17 RepositoryException.__init__(self, 'Not on any branch')
18
19class Repr(object):
20 def __repr__(self):
21 return str(self)
22
23class NoValue(object):
24 pass
25
26def make_defaults(defaults):
27 def d(val, attr):
28 if val != NoValue:
29 return val
30 elif defaults != NoValue:
31 return getattr(defaults, attr)
32 else:
33 return None
34 return d
35
a6a5abd8
KH
36class TimeZone(tzinfo, Repr):
37 def __init__(self, tzstring):
38 m = re.match(r'^([+-])(\d{2}):?(\d{2})$', tzstring)
39 if not m:
40 raise DateException(tzstring, 'time zone')
41 sign = int(m.group(1) + '1')
42 try:
43 self.__offset = timedelta(hours = sign*int(m.group(2)),
44 minutes = sign*int(m.group(3)))
45 except OverflowError:
46 raise DateException(tzstring, 'time zone')
47 self.__name = tzstring
48 def utcoffset(self, dt):
49 return self.__offset
50 def tzname(self, dt):
51 return self.__name
52 def dst(self, dt):
53 return timedelta(0)
54 def __str__(self):
55 return self.__name
56
57class Date(Repr):
58 """Immutable."""
59 def __init__(self, datestring):
60 # Try git-formatted date.
61 m = re.match(r'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring)
62 if m:
63 try:
64 self.__time = datetime.fromtimestamp(int(m.group(1)),
65 TimeZone(m.group(2)))
66 except ValueError:
67 raise DateException(datestring, 'date')
68 return
69
70 # Try iso-formatted date.
71 m = re.match(r'^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})\s+'
72 + r'([+-]\d\d:?\d\d)$', datestring)
73 if m:
74 try:
75 self.__time = datetime(
76 *[int(m.group(i + 1)) for i in xrange(6)],
77 **{'tzinfo': TimeZone(m.group(7))})
78 except ValueError:
79 raise DateException(datestring, 'date')
80 return
81
82 raise DateException(datestring, 'date')
83 def __str__(self):
84 return self.isoformat()
85 def isoformat(self):
86 """Human-friendly ISO 8601 format."""
87 return '%s %s' % (self.__time.replace(tzinfo = None).isoformat(' '),
88 self.__time.tzinfo)
89 @classmethod
90 def maybe(cls, datestring):
91 if datestring in [None, NoValue]:
92 return datestring
93 return cls(datestring)
94
cbe4567e
KH
95class Person(Repr):
96 """Immutable."""
97 def __init__(self, name = NoValue, email = NoValue,
98 date = NoValue, defaults = NoValue):
99 d = make_defaults(defaults)
100 self.__name = d(name, 'name')
101 self.__email = d(email, 'email')
102 self.__date = d(date, 'date')
a6a5abd8 103 assert isinstance(self.__date, Date) or self.__date in [None, NoValue]
cbe4567e
KH
104 name = property(lambda self: self.__name)
105 email = property(lambda self: self.__email)
106 date = property(lambda self: self.__date)
107 def set_name(self, name):
108 return type(self)(name = name, defaults = self)
109 def set_email(self, email):
110 return type(self)(email = email, defaults = self)
111 def set_date(self, date):
112 return type(self)(date = date, defaults = self)
113 def __str__(self):
114 return '%s <%s> %s' % (self.name, self.email, self.date)
115 @classmethod
116 def parse(cls, s):
117 m = re.match(r'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s)
118 assert m
119 name = m.group(1).strip()
120 email = m.group(2)
a6a5abd8 121 date = Date(m.group(3))
cbe4567e 122 return cls(name, email, date)
2057c23e
KH
123 @classmethod
124 def user(cls):
125 if not hasattr(cls, '__user'):
126 cls.__user = cls(name = config.get('user.name'),
127 email = config.get('user.email'))
128 return cls.__user
129 @classmethod
130 def author(cls):
131 if not hasattr(cls, '__author'):
132 cls.__author = cls(
133 name = os.environ.get('GIT_AUTHOR_NAME', NoValue),
134 email = os.environ.get('GIT_AUTHOR_EMAIL', NoValue),
a6a5abd8 135 date = Date.maybe(os.environ.get('GIT_AUTHOR_DATE', NoValue)),
2057c23e
KH
136 defaults = cls.user())
137 return cls.__author
138 @classmethod
139 def committer(cls):
140 if not hasattr(cls, '__committer'):
141 cls.__committer = cls(
142 name = os.environ.get('GIT_COMMITTER_NAME', NoValue),
143 email = os.environ.get('GIT_COMMITTER_EMAIL', NoValue),
a6a5abd8
KH
144 date = Date.maybe(
145 os.environ.get('GIT_COMMITTER_DATE', NoValue)),
2057c23e
KH
146 defaults = cls.user())
147 return cls.__committer
cbe4567e
KH
148
149class Tree(Repr):
150 """Immutable."""
151 def __init__(self, sha1):
152 self.__sha1 = sha1
153 sha1 = property(lambda self: self.__sha1)
154 def __str__(self):
155 return 'Tree<%s>' % self.sha1
156
157class Commitdata(Repr):
158 """Immutable."""
159 def __init__(self, tree = NoValue, parents = NoValue, author = NoValue,
160 committer = NoValue, message = NoValue, defaults = NoValue):
161 d = make_defaults(defaults)
162 self.__tree = d(tree, 'tree')
163 self.__parents = d(parents, 'parents')
164 self.__author = d(author, 'author')
165 self.__committer = d(committer, 'committer')
166 self.__message = d(message, 'message')
167 tree = property(lambda self: self.__tree)
168 parents = property(lambda self: self.__parents)
169 @property
170 def parent(self):
171 assert len(self.__parents) == 1
172 return self.__parents[0]
173 author = property(lambda self: self.__author)
174 committer = property(lambda self: self.__committer)
175 message = property(lambda self: self.__message)
176 def set_tree(self, tree):
177 return type(self)(tree = tree, defaults = self)
178 def set_parents(self, parents):
179 return type(self)(parents = parents, defaults = self)
180 def add_parent(self, parent):
181 return type(self)(parents = list(self.parents or []) + [parent],
182 defaults = self)
183 def set_parent(self, parent):
184 return self.set_parents([parent])
185 def set_author(self, author):
186 return type(self)(author = author, defaults = self)
187 def set_committer(self, committer):
188 return type(self)(committer = committer, defaults = self)
189 def set_message(self, message):
190 return type(self)(message = message, defaults = self)
dcd32afa
KH
191 def is_nochange(self):
192 return len(self.parents) == 1 and self.tree == self.parent.data.tree
cbe4567e
KH
193 def __str__(self):
194 if self.tree == None:
195 tree = None
196 else:
197 tree = self.tree.sha1
198 if self.parents == None:
199 parents = None
200 else:
201 parents = [p.sha1 for p in self.parents]
202 return ('Commitdata<tree: %s, parents: %s, author: %s,'
203 ' committer: %s, message: "%s">'
204 ) % (tree, parents, self.author, self.committer, self.message)
205 @classmethod
206 def parse(cls, repository, s):
5b7169c0 207 cd = cls(parents = [])
cbe4567e
KH
208 lines = list(s.splitlines(True))
209 for i in xrange(len(lines)):
210 line = lines[i].strip()
211 if not line:
212 return cd.set_message(''.join(lines[i+1:]))
213 key, value = line.split(None, 1)
214 if key == 'tree':
215 cd = cd.set_tree(repository.get_tree(value))
216 elif key == 'parent':
217 cd = cd.add_parent(repository.get_commit(value))
218 elif key == 'author':
219 cd = cd.set_author(Person.parse(value))
220 elif key == 'committer':
221 cd = cd.set_committer(Person.parse(value))
222 else:
223 assert False
224 assert False
225
226class Commit(Repr):
227 """Immutable."""
228 def __init__(self, repository, sha1):
229 self.__sha1 = sha1
230 self.__repository = repository
231 self.__data = None
232 sha1 = property(lambda self: self.__sha1)
233 @property
234 def data(self):
235 if self.__data == None:
236 self.__data = Commitdata.parse(
237 self.__repository,
238 self.__repository.cat_object(self.sha1))
239 return self.__data
240 def __str__(self):
241 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
242
243class Refs(object):
244 def __init__(self, repository):
245 self.__repository = repository
246 self.__refs = None
247 def __cache_refs(self):
248 self.__refs = {}
249 for line in self.__repository.run(['git', 'show-ref']).output_lines():
250 m = re.match(r'^([0-9a-f]{40})\s+(\S+)$', line)
251 sha1, ref = m.groups()
252 self.__refs[ref] = sha1
253 def get(self, ref):
254 """Throws KeyError if ref doesn't exist."""
255 if self.__refs == None:
256 self.__cache_refs()
257 return self.__repository.get_commit(self.__refs[ref])
f5c820a8
KH
258 def exists(self, ref):
259 try:
260 self.get(ref)
261 except KeyError:
262 return False
263 else:
264 return True
cbe4567e
KH
265 def set(self, ref, commit, msg):
266 if self.__refs == None:
267 self.__cache_refs()
268 old_sha1 = self.__refs.get(ref, '0'*40)
269 new_sha1 = commit.sha1
270 if old_sha1 != new_sha1:
271 self.__repository.run(['git', 'update-ref', '-m', msg,
272 ref, new_sha1, old_sha1]).no_output()
273 self.__refs[ref] = new_sha1
274 def delete(self, ref):
275 if self.__refs == None:
276 self.__cache_refs()
277 self.__repository.run(['git', 'update-ref',
278 '-d', ref, self.__refs[ref]]).no_output()
279 del self.__refs[ref]
280
281class ObjectCache(object):
282 """Cache for Python objects, for making sure that we create only one
283 Python object per git object."""
284 def __init__(self, create):
285 self.__objects = {}
286 self.__create = create
287 def __getitem__(self, name):
288 if not name in self.__objects:
289 self.__objects[name] = self.__create(name)
290 return self.__objects[name]
291 def __contains__(self, name):
292 return name in self.__objects
293 def __setitem__(self, name, val):
294 assert not name in self.__objects
295 self.__objects[name] = val
296
297class RunWithEnv(object):
298 def run(self, args, env = {}):
299 return run.Run(*args).env(utils.add_dict(self.env, env))
300
301class Repository(RunWithEnv):
302 def __init__(self, directory):
303 self.__git_dir = directory
304 self.__refs = Refs(self)
305 self.__trees = ObjectCache(lambda sha1: Tree(sha1))
306 self.__commits = ObjectCache(lambda sha1: Commit(self, sha1))
a0848ecf
KH
307 self.__default_index = None
308 self.__default_worktree = None
309 self.__default_iw = None
cbe4567e
KH
310 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
311 @classmethod
312 def default(cls):
313 """Return the default repository."""
314 try:
315 return cls(run.Run('git', 'rev-parse', '--git-dir'
316 ).output_one_line())
317 except run.RunException:
318 raise RepositoryException('Cannot find git repository')
a0848ecf 319 @property
dcd32afa 320 def default_index(self):
a0848ecf
KH
321 if self.__default_index == None:
322 self.__default_index = Index(
323 self, (os.environ.get('GIT_INDEX_FILE', None)
324 or os.path.join(self.__git_dir, 'index')))
325 return self.__default_index
dcd32afa
KH
326 def temp_index(self):
327 return Index(self, self.__git_dir)
a0848ecf 328 @property
dcd32afa 329 def default_worktree(self):
a0848ecf
KH
330 if self.__default_worktree == None:
331 path = os.environ.get('GIT_WORK_TREE', None)
332 if not path:
333 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
334 o = o or ['.']
335 assert len(o) == 1
336 path = o[0]
337 self.__default_worktree = Worktree(path)
338 return self.__default_worktree
339 @property
dcd32afa 340 def default_iw(self):
a0848ecf
KH
341 if self.__default_iw == None:
342 self.__default_iw = IndexAndWorktree(self.default_index,
343 self.default_worktree)
344 return self.__default_iw
cbe4567e
KH
345 directory = property(lambda self: self.__git_dir)
346 refs = property(lambda self: self.__refs)
347 def cat_object(self, sha1):
348 return self.run(['git', 'cat-file', '-p', sha1]).raw_output()
349 def rev_parse(self, rev):
350 try:
351 return self.get_commit(self.run(
352 ['git', 'rev-parse', '%s^{commit}' % rev]
353 ).output_one_line())
354 except run.RunException:
355 raise RepositoryException('%s: No such revision' % rev)
356 def get_tree(self, sha1):
357 return self.__trees[sha1]
358 def get_commit(self, sha1):
359 return self.__commits[sha1]
360 def commit(self, commitdata):
361 c = ['git', 'commit-tree', commitdata.tree.sha1]
362 for p in commitdata.parents:
363 c.append('-p')
364 c.append(p.sha1)
365 env = {}
366 for p, v1 in ((commitdata.author, 'AUTHOR'),
367 (commitdata.committer, 'COMMITTER')):
368 if p != None:
369 for attr, v2 in (('name', 'NAME'), ('email', 'EMAIL'),
370 ('date', 'DATE')):
371 if getattr(p, attr) != None:
a6a5abd8 372 env['GIT_%s_%s' % (v1, v2)] = str(getattr(p, attr))
cbe4567e
KH
373 sha1 = self.run(c, env = env).raw_input(commitdata.message
374 ).output_one_line()
375 return self.get_commit(sha1)
376 @property
377 def head(self):
378 try:
379 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
380 ).output_one_line()
381 except run.RunException:
382 raise DetachedHeadException()
383 def set_head(self, ref, msg):
384 self.run(['git', 'symbolic-ref', '-m', msg, 'HEAD', ref]).no_output()
dcd32afa
KH
385 def simple_merge(self, base, ours, theirs):
386 """Given three trees, tries to do an in-index merge in a temporary
387 index with a temporary index. Returns the result tree, or None if
388 the merge failed (due to conflicts)."""
389 assert isinstance(base, Tree)
390 assert isinstance(ours, Tree)
391 assert isinstance(theirs, Tree)
392
393 # Take care of the really trivial cases.
394 if base == ours:
395 return theirs
396 if base == theirs:
397 return ours
398 if ours == theirs:
399 return ours
400
401 index = self.temp_index()
402 try:
403 index.merge(base, ours, theirs)
404 try:
405 return index.write_tree()
406 except MergeException:
407 return None
408 finally:
409 index.delete()
d5d8a4f0
KH
410 def apply(self, tree, patch_text):
411 """Given a tree and a patch, will either return the new tree that
412 results when the patch is applied, or None if the patch
413 couldn't be applied."""
414 assert isinstance(tree, Tree)
415 if not patch_text:
416 return tree
417 index = self.temp_index()
418 try:
419 index.read_tree(tree)
420 try:
421 index.apply(patch_text)
422 return index.write_tree()
423 except MergeException:
424 return None
425 finally:
426 index.delete()
2558895a
KH
427 def diff_tree(self, t1, t2, diff_opts):
428 assert isinstance(t1, Tree)
429 assert isinstance(t2, Tree)
430 return self.run(['git', 'diff-tree', '-p'] + list(diff_opts)
431 + [t1.sha1, t2.sha1]).raw_output()
dcd32afa
KH
432
433class MergeException(exception.StgException):
434 pass
435
363d432f
KH
436class MergeConflictException(MergeException):
437 pass
438
dcd32afa
KH
439class Index(RunWithEnv):
440 def __init__(self, repository, filename):
441 self.__repository = repository
442 if os.path.isdir(filename):
443 # Create a temp index in the given directory.
444 self.__filename = os.path.join(
445 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
446 self.delete()
447 else:
448 self.__filename = filename
449 env = property(lambda self: utils.add_dict(
450 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
451 def read_tree(self, tree):
452 self.run(['git', 'read-tree', tree.sha1]).no_output()
453 def write_tree(self):
454 try:
455 return self.__repository.get_tree(
456 self.run(['git', 'write-tree']).discard_stderr(
457 ).output_one_line())
458 except run.RunException:
459 raise MergeException('Conflicting merge')
460 def is_clean(self):
461 try:
462 self.run(['git', 'update-index', '--refresh']).discard_output()
463 except run.RunException:
464 return False
465 else:
466 return True
467 def merge(self, base, ours, theirs):
468 """In-index merge, no worktree involved."""
469 self.run(['git', 'read-tree', '-m', '-i', '--aggressive',
470 base.sha1, ours.sha1, theirs.sha1]).no_output()
d5d8a4f0
KH
471 def apply(self, patch_text):
472 """In-index patch application, no worktree involved."""
473 try:
474 self.run(['git', 'apply', '--cached']
475 ).raw_input(patch_text).no_output()
476 except run.RunException:
477 raise MergeException('Patch does not apply cleanly')
dcd32afa
KH
478 def delete(self):
479 if os.path.isfile(self.__filename):
480 os.remove(self.__filename)
a639e7bb
KH
481 def conflicts(self):
482 """The set of conflicting paths."""
483 paths = set()
484 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
485 ).raw_output().split('\0')[:-1]:
486 stat, path = line.split('\t', 1)
487 paths.add(path)
488 return paths
dcd32afa
KH
489
490class Worktree(object):
491 def __init__(self, directory):
492 self.__directory = directory
493 env = property(lambda self: { 'GIT_WORK_TREE': self.__directory })
8d96d568 494 directory = property(lambda self: self.__directory)
dcd32afa
KH
495
496class CheckoutException(exception.StgException):
497 pass
498
499class IndexAndWorktree(RunWithEnv):
500 def __init__(self, index, worktree):
501 self.__index = index
502 self.__worktree = worktree
503 index = property(lambda self: self.__index)
504 env = property(lambda self: utils.add_dict(self.__index.env,
505 self.__worktree.env))
506 def checkout(self, old_tree, new_tree):
507 # TODO: Optionally do a 3-way instead of doing nothing when we
508 # have a problem. Or maybe we should stash changes in a patch?
509 assert isinstance(old_tree, Tree)
510 assert isinstance(new_tree, Tree)
511 try:
512 self.run(['git', 'read-tree', '-u', '-m',
513 '--exclude-per-directory=.gitignore',
514 old_tree.sha1, new_tree.sha1]
8d96d568 515 ).cwd(self.__worktree.directory).discard_output()
dcd32afa
KH
516 except run.RunException:
517 raise CheckoutException('Index/workdir dirty')
518 def merge(self, base, ours, theirs):
519 assert isinstance(base, Tree)
520 assert isinstance(ours, Tree)
521 assert isinstance(theirs, Tree)
522 try:
363d432f
KH
523 r = self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
524 theirs.sha1],
525 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
526 'GITHEAD_%s' % ours.sha1: 'current',
527 'GITHEAD_%s' % theirs.sha1: 'patched'}
528 ).cwd(self.__worktree.directory)
529 r.discard_output()
dcd32afa 530 except run.RunException, e:
363d432f
KH
531 if r.exitcode == 1:
532 raise MergeConflictException()
533 else:
534 raise MergeException('Index/worktree dirty')
dcd32afa
KH
535 def changed_files(self):
536 return self.run(['git', 'diff-files', '--name-only']).output_lines()
537 def update_index(self, files):
538 self.run(['git', 'update-index', '--remove', '-z', '--stdin']
539 ).input_nulterm(files).discard_output()