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