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