2 from datetime import datetime, timedelta, tzinfo
4 from stgit import exception, run, utils
5 from stgit.config import config
7 class RepositoryException(exception.StgException):
10 class DateException(exception.StgException):
11 def __init__(self, string, type):
12 exception.StgException.__init__(
13 self, '"%s" is not a valid %s' % (string, type))
15 class DetachedHeadException(RepositoryException):
17 RepositoryException.__init__(self, 'Not on any branch')
23 class NoValue(object):
26 def make_defaults(defaults):
30 elif defaults != NoValue:
31 return getattr(defaults, attr)
36 class TimeZone(tzinfo, Repr):
37 def __init__(self, tzstring):
38 m = re.match(r'^([+-])(\d{2}):?(\d{2})$', tzstring)
40 raise DateException(tzstring, 'time zone')
41 sign = int(m.group(1) + '1')
43 self.__offset = timedelta(hours = sign*int(m.group(2)),
44 minutes = sign*int(m.group(3)))
46 raise DateException(tzstring, 'time zone')
47 self.__name = tzstring
48 def utcoffset(self, dt):
59 def __init__(self, datestring):
60 # Try git-formatted date.
61 m = re.match(r'^(\d+)\s+([+-]\d\d:?\d\d)$', datestring)
64 self.__time = datetime.fromtimestamp(int(m.group(1)),
67 raise DateException(datestring, 'date')
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)
75 self.__time = datetime(
76 *[int(m.group(i + 1)) for i in xrange(6)],
77 **{'tzinfo': TimeZone(m.group(7))})
79 raise DateException(datestring, 'date')
82 raise DateException(datestring, 'date')
84 return self.isoformat()
86 """Human-friendly ISO 8601 format."""
87 return '%s %s' % (self.__time.replace(tzinfo = None).isoformat(' '),
90 def maybe(cls, datestring):
91 if datestring in [None, NoValue]:
93 return cls(datestring)
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')
103 assert isinstance(self.__date, Date) or self.__date in [None, NoValue]
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)
114 return '%s <%s> %s' % (self.name, self.email, self.date)
117 m = re.match(r'^([^<]*)<([^>]*)>\s+(\d+\s+[+-]\d{4})$', s)
119 name = m.group(1).strip()
121 date = Date(m.group(3))
122 return cls(name, email, date)
125 if not hasattr(cls, '__user'):
126 cls.__user = cls(name = config.get('user.name'),
127 email = config.get('user.email'))
131 if not hasattr(cls, '__author'):
133 name = os.environ.get('GIT_AUTHOR_NAME', NoValue),
134 email = os.environ.get('GIT_AUTHOR_EMAIL', NoValue),
135 date = Date.maybe(os.environ.get('GIT_AUTHOR_DATE', NoValue)),
136 defaults = cls.user())
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),
145 os.environ.get('GIT_COMMITTER_DATE', NoValue)),
146 defaults = cls.user())
147 return cls.__committer
151 def __init__(self, sha1):
153 sha1 = property(lambda self: self.__sha1)
155 return 'Tree<%s>' % self.sha1
157 class Commitdata(Repr):
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)
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],
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)
191 def is_nochange(self):
192 return len(self.parents) == 1 and self.tree == self.parent.data.tree
194 if self.tree == None:
197 tree = self.tree.sha1
198 if self.parents == None:
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)
206 def parse(cls, repository, s):
208 lines = list(s.splitlines(True))
209 for i in xrange(len(lines)):
210 line = lines[i].strip()
212 return cd.set_message(''.join(lines[i+1:]))
213 key, value = line.split(None, 1)
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))
228 def __init__(self, repository, sha1):
230 self.__repository = repository
232 sha1 = property(lambda self: self.__sha1)
235 if self.__data == None:
236 self.__data = Commitdata.parse(
238 self.__repository.cat_object(self.sha1))
241 return 'Commit<sha1: %s, data: %s>' % (self.sha1, self.__data)
244 def __init__(self, repository):
245 self.__repository = repository
247 def __cache_refs(self):
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
254 """Throws KeyError if ref doesn't exist."""
255 if self.__refs == None:
257 return self.__repository.get_commit(self.__refs[ref])
258 def exists(self, ref):
265 def set(self, ref, commit, msg):
266 if self.__refs == None:
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:
277 self.__repository.run(['git', 'update-ref',
278 '-d', ref, self.__refs[ref]]).no_output()
281 class 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):
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
297 class RunWithEnv(object):
298 def run(self, args, env = {}):
299 return run.Run(*args).env(utils.add_dict(self.env, env))
301 class 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))
307 self.__default_index = None
308 self.__default_worktree = None
309 self.__default_iw = None
310 env = property(lambda self: { 'GIT_DIR': self.__git_dir })
313 """Return the default repository."""
315 return cls(run.Run('git', 'rev-parse', '--git-dir'
317 except run.RunException:
318 raise RepositoryException('Cannot find git repository')
320 def default_index(self):
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
326 def temp_index(self):
327 return Index(self, self.__git_dir)
329 def default_worktree(self):
330 if self.__default_worktree == None:
331 path = os.environ.get('GIT_WORK_TREE', None)
333 o = run.Run('git', 'rev-parse', '--show-cdup').output_lines()
337 self.__default_worktree = Worktree(path)
338 return self.__default_worktree
340 def default_iw(self):
341 if self.__default_iw == None:
342 self.__default_iw = IndexAndWorktree(self.default_index,
343 self.default_worktree)
344 return self.__default_iw
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):
351 return self.get_commit(self.run(
352 ['git', 'rev-parse', '%s^{commit}' % rev]
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:
366 for p, v1 in ((commitdata.author, 'AUTHOR'),
367 (commitdata.committer, 'COMMITTER')):
369 for attr, v2 in (('name', 'NAME'), ('email', 'EMAIL'),
371 if getattr(p, attr) != None:
372 env['GIT_%s_%s' % (v1, v2)] = str(getattr(p, attr))
373 sha1 = self.run(c, env = env).raw_input(commitdata.message
375 return self.get_commit(sha1)
379 return self.run(['git', 'symbolic-ref', '-q', 'HEAD']
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()
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)
393 # Take care of the really trivial cases.
401 index = self.temp_index()
403 index.merge(base, ours, theirs)
405 return index.write_tree()
406 except MergeException:
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)
417 index = self.temp_index()
419 index.read_tree(tree)
421 index.apply(patch_text)
422 return index.write_tree()
423 except MergeException:
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()
433 class MergeException(exception.StgException):
436 class 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)))
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):
452 return self.__repository.get_tree(
453 self.run(['git', 'write-tree']).discard_stderr(
455 except run.RunException:
456 raise MergeException('Conflicting merge')
459 self.run(['git', 'update-index', '--refresh']).discard_output()
460 except run.RunException:
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()
468 def apply(self, patch_text):
469 """In-index patch application, no worktree involved."""
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')
476 if os.path.isfile(self.__filename):
477 os.remove(self.__filename)
479 """The set of conflicting paths."""
481 for line in self.run(['git', 'ls-files', '-z', '--unmerged']
482 ).raw_output().split('\0')[:-1]:
483 stat, path = line.split('\t', 1)
487 class Worktree(object):
488 def __init__(self, directory):
489 self.__directory = directory
490 env = property(lambda self: { 'GIT_WORK_TREE': self.__directory })
491 directory = property(lambda self: self.__directory)
493 class CheckoutException(exception.StgException):
496 class IndexAndWorktree(RunWithEnv):
497 def __init__(self, index, worktree):
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)
509 self.run(['git', 'read-tree', '-u', '-m',
510 '--exclude-per-directory=.gitignore',
511 old_tree.sha1, new_tree.sha1]
512 ).cwd(self.__worktree.directory).discard_output()
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)
520 self.run(['git', 'merge-recursive', base.sha1, '--', ours.sha1,
522 env = { 'GITHEAD_%s' % base.sha1: 'ancestor',
523 'GITHEAD_%s' % ours.sha1: 'current',
524 'GITHEAD_%s' % theirs.sha1: 'patched'}
525 ).cwd(self.__worktree.directory).discard_output()
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()