Use a special exit code for bugs
[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):
207 cd = cls()
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
436class Index(RunWithEnv):
437 def __init__(self, repository, filename):
438 self.__repository = repository
439 if os.path.isdir(filename):
440 # Create a temp index in the given directory.
441 self.__filename = os.path.join(
442 filename, 'index.temp-%d-%x' % (os.getpid(), id(self)))
443 self.delete()
444 else:
445 self.__filename = filename
446 env = property(lambda self: utils.add_dict(
447 self.__repository.env, { 'GIT_INDEX_FILE': self.__filename }))
448 def read_tree(self, tree):
449 self.run(['git', 'read-tree', tree.sha1]).no_output()
450 def write_tree(self):
451 try:
452 return self.__repository.get_tree(
453 self.run(['git', 'write-tree']).discard_stderr(
454 ).output_one_line())
455 except run.RunException:
456 raise MergeException('Conflicting merge')
457 def is_clean(self):
458 try:
459 self.run(['git', 'update-index', '--refresh']).discard_output()
460 except run.RunException:
461 return False
462 else:
463 return True
464 def merge(self, base, ours, theirs):
465 """In-index merge, no worktree involved."""
466 self.run(['git', 'read-tree', '-m', '-i', '--aggressive',
467 base.sha1, ours.sha1, theirs.sha1]).no_output()
d5d8a4f0
KH
468 def apply(self, patch_text):
469 """In-index patch application, no worktree involved."""
470 try:
471 self.run(['git', 'apply', '--cached']
472 ).raw_input(patch_text).no_output()
473 except run.RunException:
474 raise MergeException('Patch does not apply cleanly')
dcd32afa
KH
475 def delete(self):
476 if os.path.isfile(self.__filename):
477 os.remove(self.__filename)
a639e7bb
KH
478 def conflicts(self):
479 """The set of conflicting paths."""
480 paths = set()
481 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
482 ).raw_output().split('\0')[:-1]:
483 stat, path = line.split('\t', 1)
484 paths.add(path)
485 return paths
dcd32afa
KH
486
487class Worktree(object):
488 def __init__(self, directory):
489 self.__directory = directory
490 env = property(lambda self: { 'GIT_WORK_TREE': self.__directory })
8d96d568 491 directory = property(lambda self: self.__directory)
dcd32afa
KH
492
493class CheckoutException(exception.StgException):
494 pass
495
496class IndexAndWorktree(RunWithEnv):
497 def __init__(self, index, worktree):
498 self.__index = index
499 self.__worktree = worktree
500 index = property(lambda self: self.__index)
501 env = property(lambda self: utils.add_dict(self.__index.env,
502 self.__worktree.env))
503 def checkout(self, old_tree, new_tree):
504 # TODO: Optionally do a 3-way instead of doing nothing when we
505 # have a problem. Or maybe we should stash changes in a patch?
506 assert isinstance(old_tree, Tree)
507 assert isinstance(new_tree, Tree)
508 try:
509 self.run(['git', 'read-tree', '-u', '-m',
510 '--exclude-per-directory=.gitignore',
511 old_tree.sha1, new_tree.sha1]
8d96d568 512 ).cwd(self.__worktree.directory).discard_output()
dcd32afa
KH
513 except run.RunException:
514 raise CheckoutException('Index/workdir dirty')
515 def merge(self, base, ours, theirs):
516 assert isinstance(base, Tree)
517 assert isinstance(ours, Tree)
518 assert isinstance(theirs, Tree)
519 try:
520 self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
521 theirs.sha1],
522 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
523 'GITHEAD_%s' % ours.sha1: 'current',
524 'GITHEAD_%s' % theirs.sha1: 'patched'}
8d96d568 525 ).cwd(self.__worktree.directory).discard_output()
dcd32afa
KH
526 except run.RunException, e:
527 raise MergeException('Index/worktree dirty')
528 def changed_files(self):
529 return self.run(['git', 'diff-files', '--name-only']).output_lines()
530 def update_index(self, files):
531 self.run(['git', 'update-index', '--remove', '-z', '--stdin']
532 ).input_nulterm(files).discard_output()