chiark / gitweb /
50dc4f112408e35d9f384a4cf188129d84e18496
[stgit] / stgit / lib / git.py
1 import os, os.path, re
2 from datetime import datetime, timedelta, tzinfo
3
4 from stgit import exception, run, utils
5 from stgit.config import config
6
7 class RepositoryException(exception.StgException):
8     pass
9
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))
14
15 class DetachedHeadException(RepositoryException):
16     def __init__(self):
17         RepositoryException.__init__(self, 'Not on any branch')
18
19 class Repr(object):
20     def __repr__(self):
21         return str(self)
22
23 class NoValue(object):
24     pass
25
26 def 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
36 class 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
57 class 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
95 class 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')
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)
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)
121         date = Date(m.group(3))
122         return cls(name, email, date)
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),
135                 date = Date.maybe(os.environ.get('GIT_AUTHOR_DATE', NoValue)),
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),
144                 date = Date.maybe(
145                     os.environ.get('GIT_COMMITTER_DATE', NoValue)),
146                 defaults = cls.user())
147         return cls.__committer
148
149 class 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
157 class 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)
191     def is_nochange(self):
192         return len(self.parents) == 1 and self.tree == self.parent.data.tree
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
226 class 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
243 class 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])
258     def exists(self, ref):
259         try:
260             self.get(ref)
261         except KeyError:
262             return False
263         else:
264             return True
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
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):
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
297 class RunWithEnv(object):
298     def run(self, args, env = {}):
299         return run.Run(*args).env(utils.add_dict(self.env, env))
300
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 })
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')
319     @property
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)
328     @property
329     def default_worktree(self):
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
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):
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:
372                         env['GIT_%s_%s' % (v1, v2)] = str(getattr(p, attr))
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()
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()
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()
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()
432
433 class MergeException(exception.StgException):
434     pass
435
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)))
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()
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')
475     def delete(self):
476         if os.path.isfile(self.__filename):
477             os.remove(self.__filename)
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
486
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)
492
493 class CheckoutException(exception.StgException):
494     pass
495
496 class 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]
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)
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'}
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()